227 Php Developer Interview Questions & Answers

116 top • 13 Amazon • 21 Google • 10 Netflix • 7 Meta • 18 NVIDIA • 21 Apple • 21 Microsoft

Php Developer icon

Questions with Detailed ExplanationsWith Detailed Explanations

(Last Updated: September 3, 2026)

51. What is an ORM in PHP?Sql / DatabaseEasy

Question Details

Define object-relational mapping as translating between PHP objects and relational database rows. Explain entities, mapping metadata, identity, repositories, unit of work, change tracking, relationships, lazy and eager loading, generated SQL, transactions, and migrations. Use Doctrine ORM as an example and explain the N+1, hidden-query, and abstraction tradeoffs.

Short Interview Answer (30-60 seconds)

An ORM maps PHP objects to rows in relational database tables. It lets application code load, create, update, and relate objects while the ORM generates the required SQL. Doctrine ORM is a common PHP example. It reduces repetitive persistence code, but developers still need to understand its generated queries and loading behavior.

Detailed Explanation

An ORM is a tool that helps a PHP program store and read information without making the developer write every database command by hand. The program works with PHP objects, such as a User or Order, while the tool handles much of the work needed to save and retrieve their information. This can make application code easier to organize and maintain. However, the developer still needs to understand what happens behind the scenes because a simple-looking operation can sometimes cause many database requests or load much more information than expected.

Useful Questions to Ask the Interviewer
  1. Would you like a general ORM explanation, or should I use Doctrine ORM as the example?
  2. Should I also explain common performance problems such as N+1 queries and lazy loading?
What is an ORM in PHP? diagram
How to Explain It in an Interview

ORM means Object-Relational Mapping. It translates between PHP objects and rows in relational database tables. For example, a PHP User object can represent one row in a users table, and its properties can correspond to columns such as id, name, and email.

An entity is a PHP object whose state can be persisted in the database. Mapping metadata tells the ORM how an entity maps to a table, which property maps to each column, which field is the identifier, and how relationships are represented. Doctrine ORM commonly supports mapping with PHP attributes or XML configuration.

Identity matters because one database row identified by one primary key represents one logical entity. Doctrine's identity map keeps track of managed entities so that, within the same EntityManager and persistence context, repeated loading of the same entity identity normally refers to the same managed PHP object instance.

A repository provides an abstraction for finding and querying entities. For example, a repository can find a user by its identifier or provide application-specific query methods. The entity represents application data and behavior, while the repository is commonly used for retrieval logic.

Doctrine ORM uses the Unit of Work pattern. The EntityManager manages entity state, and the Unit of Work tracks managed entities and determines which database changes are needed. When the application modifies managed objects and later calls flush(), Doctrine calculates the required changes and generates SQL such as INSERT, UPDATE, and DELETE statements. Calling persist() on a new entity makes Doctrine manage it for insertion, but it does not by itself guarantee that an SQL INSERT is immediately executed. Database synchronization normally occurs during flush().

Relationships represent associations between entities, such as one customer having many orders or one order belonging to one customer. ORM mapping can describe one-to-one, one-to-many, many-to-one, and many-to-many associations. At the database level, these relationships are implemented using relational structures such as foreign keys and, for many-to-many relationships, usually a join table.

Loading strategy affects performance. Lazy loading delays retrieving related data until that relationship is accessed. This can avoid unnecessary work, but it can also cause SQL to execute at a point that is not obvious from the PHP code. Eager or explicit fetch strategies retrieve related data earlier when the application knows it will need it. Fetching too much data, however, can increase query cost, result size, and PHP memory use.

A classic ORM performance problem is the N+1 query problem. For example, the application may run one query to load 100 orders and then cause one additional query for the customer of each order. That can produce 101 queries. A suitable fetch join or another deliberate fetching strategy can often reduce those database round trips. The developer should still inspect the resulting SQL because joining several collection relationships can also produce very large result sets.

Hidden queries are another tradeoff. Accessing a lazily loaded association can look like an ordinary PHP object operation while causing a database query. Developers should use SQL logging or profiling during development and inspect important database query plans when performance matters.

An ORM is an abstraction, not a replacement for SQL or relational database knowledge. It reduces repetitive object-mapping and persistence code, but generated SQL is not automatically optimal for every task. Complex reports, bulk operations, database-specific features, or carefully optimized queries may be clearer or more efficient with explicit queries or a lower-level database abstraction.

Transactions still matter. Several related writes that must succeed or fail together should be performed within one appropriate database transaction. Doctrine normally executes queued write operations from a flush() within a transaction. Applications that need several operations, reads, decisions, or multiple flushes to form one atomic business operation should define the transaction boundary explicitly. Database isolation levels, constraints, and locking rules still determine important consistency and concurrency behavior.

Migrations solve a different problem from ORM persistence. ORM mapping describes how PHP entities correspond to the current database schema. A migration records a controlled schema change, such as creating a table, adding a column, or adding an index. In a Doctrine-based application, schema changes are commonly managed with the separate Doctrine Migrations package so they can be applied consistently across environments.

The practical decision is to use an ORM when object-oriented application code benefits from consistent entity mapping, relationship handling, identity management, change tracking, and reusable persistence logic. I would still monitor generated SQL, choose loading strategies deliberately, keep transaction boundaries clear, and enforce important rules with database constraints. For performance-sensitive, bulk, reporting, or database-specific work, I would use explicit queries when they are clearer or more efficient.

Technical Approach
  1. Define entities that represent persistent application data.
  2. Define mapping metadata for tables, columns, identifiers, and relationships.
  3. Retrieve entities through repositories or ORM queries.
  4. Let the EntityManager manage the entities that participate in the current unit of work.
  5. Modify PHP objects in application code.
  6. Use an appropriate database transaction boundary when several operations must be atomic.
  7. Call flush() so Doctrine calculates changes and executes the required SQL.
  8. Review generated SQL and relationship-loading behavior for N+1 queries, unnecessary joins, excessive data, or hidden queries.
  9. Use migrations to manage database schema changes separately from runtime entity persistence.
  10. Use explicit SQL or lower-level database access when the ORM abstraction makes an important operation harder to understand, express, or optimize.
Practical Insights

There is no single Big O cost for using an ORM because the cost depends on the queries and object graph being processed. The main practical costs are database round trips, rows transferred, PHP objects created, and entities tracked by the Unit of Work. A simple lookup can be inexpensive, while an N+1 pattern can turn one logical operation into many database queries. Loading large object graphs can also consume significant PHP memory. Large batch jobs may require batching work and clearing managed entities periodically, or may be better implemented with bulk SQL. Operationally, an ORM reduces repetitive persistence code but adds mapping configuration, generated-query inspection, migration maintenance, and ORM-specific knowledge. Database indexes, constraints, query plans, transaction duration, and result-set sizes still directly affect performance.

Why Interviewers Ask This

Interviewers want to know whether the candidate understands what an ORM actually does instead of treating it as a replacement for database knowledge. A strong answer explains object-to-row mapping, entities, mapping metadata, identity, repositories, the Unit of Work, change tracking, relationships, lazy and eager loading, generated SQL, transactions, migrations, and important tradeoffs such as N+1 queries and hidden database work.

Common interview mistakes

A common mistake is saying that an ORM means developers no longer need SQL knowledge. Another is assuming every object access is only an in-memory operation; lazy loading can trigger hidden SQL. Developers may accidentally create an N+1 query pattern by looping over entities and accessing an unloaded relationship. Eager loading everything is not a universal fix because it can retrieve excessive data or create very large joined result sets. Another mistake is confusing persist() with an immediate database insert; in Doctrine ORM, synchronization with the database normally happens during flush(). Developers should also not rely on ORM behavior instead of database constraints, indexes, or proper transactions. Finally, migrations should not be confused with runtime persistence: migrations change database structure, while the ORM maps and persists application data.

Interview tip

Start with one sentence: an ORM maps PHP objects to relational database rows. Then use Doctrine ORM to explain entities, mapping metadata, repositories, identity, the Unit of Work, change tracking, relationships, and flush(). Finish by showing judgment: mention N+1 queries, hidden lazy-loading queries, transactions, generated SQL, migrations, and when explicit SQL may be a better choice.

Interviewer may ask next
What is the N+1 query problem in an ORM, and how can you avoid it?

The N+1 problem happens when one query loads a collection of N entities and accessing a related object causes one additional query for each entity. For example, one query may load 100 orders and another 100 queries may load their customers. In Doctrine ORM, a suitable fetch join or another deliberate loading strategy can often reduce the number of database round trips. The developer should also inspect the resulting SQL and result size because fetching too many related rows in one query can create a different performance problem.

What is the difference between lazy loading and eager loading?

Lazy loading delays retrieving related data until the application accesses that relationship. It can save work when the related data is never needed, but it can create hidden queries and N+1 problems. Eager or explicit fetching retrieves related data earlier, which can reduce later queries when that data is definitely needed, but it can also retrieve unnecessary data or produce large result sets. The correct strategy depends on the application's actual access pattern.

52. What is the difference between PDO and MySQLi in PHP?Sql / DatabaseEasy

Question Details

Compare database support, procedural versus object-oriented APIs, named parameters, prepared statements, transactions, error handling, and portability.

Short Interview Answer (30-60 seconds)

PDO supports multiple database drivers, named or positional placeholders, and an object-oriented API. MySQLi targets MySQL and provides object-oriented and procedural APIs with positional placeholders. Both support prepared statements, transactions, and exceptions. Choose PDO for broader portability and MySQLi for MySQL-specific development.

Detailed Explanation

See the Code while reading this explanation.

This question asks you to compare two ways a PHP program can save and read information. The interviewer wants to know which choice works with more storage products, which writing styles each choice provides, and how they handle safe input, groups of changes, and failures. You should also explain whether changing the storage product later would be easier and when closer access to one product is useful. A good answer does not say that one choice is always better. It selects the choice that matches the application's actual needs.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Will the application use only MySQL, or might it support another database later?
  • Does the application require any MySQL-specific feature?
  • Does the team prefer named placeholders or a procedural API?
What is the difference between PDO and MySQLi in PHP? diagram
How to Explain It in an Interview

PDO means PHP Data Objects. It is a core PHP database-access extension that provides one object-oriented interface for multiple database drivers, including MySQL, PostgreSQL and SQLite. The correct driver must be installed for the selected database. PDO is a data-access abstraction, not a complete database abstraction layer: it gives PHP code a similar interface, but it does not rewrite database-specific SQL or make schemas and behavior identical.

MySQLi means MySQL Improved. It is a core PHP extension designed specifically for MySQL. It is also commonly used with MariaDB because MariaDB supports the MySQL client protocol, but compatibility with a particular MariaDB feature or version must still be verified. MySQLi offers both object-oriented and procedural APIs. PDO offers an object-oriented API only.

PDO supports named placeholders such as :email and positional placeholders such as ?. A single PDO statement must use one placeholder style, not mix both styles. MySQLi prepared statements use positional ? placeholders and normally require a type string when values are bound with bind_param().

In both extensions, placeholders represent complete data values only. They cannot represent a table name, column name, SQL keyword, sort direction, operator, or an entire list of values. Dynamic identifiers or keywords must be selected from a strict trusted allowlist. Untrusted input must never be inserted directly into those parts of the SQL statement.

Both PDO and MySQLi support prepared statements. Prepared statements separate the SQL structure from its values, which prevents bound values from being interpreted as SQL. They can also reduce repeated preparation work when the same statement is executed many times. They are not automatically faster for a single execution because preparing and executing may require additional work or network round trips.

PDO drivers may use native server-side prepared statements or emulated prepares. With the MySQL PDO driver, PDO::ATTR_EMULATE_PREPARES => false requests native prepares. Native and emulated modes can differ in parsing, supported statements, reported error timing and placeholder handling, so the application should test the selected mode instead of assuming they behave identically. MySQLi prepared statements use MySQL's prepared-statement protocol.

Both extensions support transactions through methods for beginning, committing and rolling back a transaction. Transactions work only when the selected database objects and statements are transactional. For example, a MySQL table using a non-transactional storage engine cannot gain rollback behavior merely because PDO or MySQLi is used. Neither extension changes the database's isolation level, locking rules, constraints or implicit-commit behavior. Transaction boundaries should be explicit and short, and every failure path should either roll back or safely end the connection.

PDO reports failures with PDOException when exception mode is active. Modern PHP uses exception mode by default for PDO, but setting PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION explicitly makes the intended behavior clear. MySQLi throws mysqli_sql_exception when strict error reporting is enabled. Modern PHP enables strict MySQLi reporting by default, but calling mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT) explicitly avoids relying on environment assumptions. Production code should log internal database details securely and return a generic message to the client.

PDO and MySQLi normally create non-persistent connections, which PHP closes when the connection object is destroyed or the request ends. PDO can request persistent connections with PDO::ATTR_PERSISTENT, but persistence is not automatically faster and may retain session state, temporary settings or an unfinished transaction if cleanup is poor. Connection pooling and persistence should therefore be chosen only after measurement and careful state management.

The practical decision is: choose PDO when the application needs a consistent API across supported database drivers or benefits from named placeholders. Choose MySQLi when the application is intentionally MySQL-specific, requires its procedural API, or benefits from direct MySQL-oriented functionality. Security and performance depend mainly on correct SQL, parameter binding, indexes, result size, transaction design and connection management rather than on the extension name alone.

Key Insight / Why This Solution Works
  1. Confirm which database servers the application must support.
  2. Check whether database portability is a real requirement rather than a theoretical possibility.
  3. Identify any required MySQL-specific functionality.
  4. Decide whether named placeholders or a procedural API matters to the codebase.
  5. Choose and explicitly configure exception handling and prepared-statement behavior.
  6. Use prepared statements for untrusted values and allowlists for dynamic identifiers or keywords.
  7. Define clear transaction boundaries and rollback paths.
  8. Benchmark real queries before making performance or persistent-connection decisions.
Code
<?php

declare(strict_types=1);

function findUserWithPdo(string $email): ?array
{
    $pdo = new PDO(
        'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
        'app_user',
        'replace_with_secret',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]
    );

    $statement = $pdo->prepare(
        'SELECT id, email, display_name FROM users WHERE email = :email LIMIT 1'
    );
    $statement->execute(['email' => $email]);

    $user = $statement->fetch();
    $statement->closeCursor();
    $pdo = null;

    return $user === false ? null : $user;
}

function findUserWithMysqli(string $email): ?array
{
    mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

    $mysqli = new mysqli(
        '127.0.0.1',
        'app_user',
        'replace_with_secret',
        'app'
    );
    $mysqli->set_charset('utf8mb4');

    $statement = $mysqli->prepare(
        'SELECT id, email, display_name FROM users WHERE email = ? LIMIT 1'
    );
    $statement->bind_param('s', $email);
    $statement->execute();
    $statement->bind_result($id, $storedEmail, $displayName);

    $user = null;

    if ($statement->fetch()) {
        $user = [
            'id' => $id,
            'email' => $storedEmail,
            'display_name' => $displayName,
        ];
    }

    $statement->close();
    $mysqli->close();

    return $user;
}

try {
    $email = 'candidate@example.com';

    var_dump(findUserWithPdo($email));
    var_dump(findUserWithMysqli($email));
} catch (PDOException | mysqli_sql_exception $exception) {
    error_log($exception->getMessage());
    exit('A database operation failed.');
}
Why Interviewers Ask This

Interviewers ask this question to verify that a PHP developer understands the language's two main database-access extensions and can choose between portability and MySQL-specific functionality. The answer also reveals whether the candidate understands API styles, placeholders, prepared statements, transactions, error handling, connection lifecycle, and the limits of database portability.

Common interview mistakes

Common mistakes include calling PDO an ORM, claiming PDO makes SQL fully portable, or saying MySQLi has no object-oriented API. Candidates may incorrectly claim that PDO supports only named placeholders or that MySQLi supports named placeholders. Another serious mistake is using placeholders for table names, column names, sort directions or a comma-separated IN list. Other errors include assuming prepared statements are always faster, assuming all MySQL tables support rollback, ignoring PDO emulated-prepare differences, relying on mysqli_stmt::get_result() without considering mysqlnd, exposing database exceptions to users, or enabling persistent connections without resetting connection state.

Interview tip

Start with the decision: PDO for multiple database drivers and named placeholders; MySQLi for a deliberately MySQL-specific application and procedural or direct MySQL-oriented access. Then compare prepared statements, transactions and exceptions. Mention that PDO does not make SQL portable and that neither extension makes dynamic identifiers safe.

Interviewer may ask next
Does using PDO make an application fully portable between MySQL and PostgreSQL?

No. PDO provides a similar PHP interface through different drivers, but it does not rewrite SQL or normalize database behavior. SQL syntax, data types, generated keys, schema definitions, functions, error codes, transaction behavior and locking rules may differ. Real portability requires intentionally portable SQL, database-specific adapters and testing against every supported database.

Can PDO or MySQLi placeholders be used for table names, sort directions or an IN list?

No. A placeholder represents one complete data value. It cannot represent an identifier, keyword, operator or several comma-separated values. Select table names, column names and sort directions from strict trusted allowlists. For an IN condition, generate one placeholder for each value and bind every value separately.

53. How do you connect a PHP application to MySQL using PDO?Sql / DatabaseEasy

Question Details

Describe the PDO connection code, DSN, character set, credentials handling, exception mode, and how connection failures should be handled without exposing secrets.

Short Interview Answer (30-60 seconds)

I create a MySQL DSN with the host, port, database name, and utf8mb4. I load credentials from protected configuration, enable PDO exception mode, disable emulated prepares, and catch connection failures so internal logs receive safe details while users receive only a generic error.

Detailed Explanation

See the Code while reading this explanation.

This question asks how a PHP application safely opens a path to information stored in MySQL. A complete answer should explain where the address, data name, user name, and password come from, how different languages and symbols are handled, and what happens when the path cannot be opened. Private values must not be written directly in the program or shown to users. The application should record a safe support reference and display a simple message that does not reveal private settings or internal system details.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Are credentials supplied through environment variables or a secret-management service?
  • Does the deployment require encrypted MySQL connections with certificate verification?
  • Is this a normal web request or a long-running worker?
How do you connect a PHP application to MySQL using PDO? diagram
How to Explain It in an Interview

PDO means PHP Data Objects. It is a PHP extension that provides a common interface for database access. For MySQL, I create a DSN, which is a connection string that identifies the driver and connection settings.

A typical MySQL DSN contains the host, port, database name, and charset=utf8mb4. The utf8mb4 character set supports the full Unicode range and should be established when the connection is created.

I do not hard-code production credentials in PHP files or commit them to version control. I load the username and password from protected deployment configuration, environment variables, or a secret-management service. Access to those values should be restricted, and secret values must not be included in logs.

I create PDO with explicit options. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION makes connection and database-operation failures throw PDOException. Although exception mode is the default in modern PHP, setting it explicitly makes the intended behavior clear. PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC makes fetched rows use column names as array keys. PDO::ATTR_EMULATE_PREPARES => false requests native MySQL prepared statements when the driver and statement support them.

The connection attempt belongs inside a try block. If it fails, PDO throws PDOException. At the application boundary, I catch the exception, create a support identifier, and log only details allowed by the organization's logging policy, such as the exception class, a non-secret error code, and the support identifier. I do not send the raw exception message, DSN, host, database name, username, password, or driver diagnostics to the client because those values can expose internal information.

The user should receive a generic response such as Service temporarily unavailable. The internal support identifier can be returned so an operator can match the user report to the server log.

Creating PDO opens the connection when the PDO object is constructed. In a normal PHP web request, the connection is normally released when the PDO object is destroyed or when the request ends. Setting the variable to null can release the application's reference earlier, although another reference would keep the object alive. Long-running workers need additional lifecycle handling because MySQL may close idle or stale connections. Such workers should detect a failed operation and establish a new connection through controlled retry logic rather than assuming one connection remains valid forever.

Prepared statements are used after the connection is established when executing SQL with untrusted values. Bound parameters protect data values. They do not make dynamic table names, column names, keywords, or sort directions safe. Dynamic identifiers must be selected from a strict allowlist.

If the deployment requires encrypted transport, I configure the PDO MySQL TLS options required by that environment and verify the server certificate. TLS settings are deployment-specific and should not be added as unverified placeholders.

Key Insight / Why This Solution Works
  1. Read the host, port, database name, username, and password from protected configuration.
  2. Verify that every required value is present and validate that the port is a valid number.
  3. Build a MySQL DSN containing the host, port, database name, and charset=utf8mb4.
  4. Construct PDO with exception mode, associative fetch mode, and emulated prepares disabled.
  5. Catch configuration and connection failures at the application boundary.
  6. Generate a support identifier and log only approved non-secret details.
  7. Return a generic service error without exposing raw exception messages or connection settings.
  8. Reuse the connection during the current unit of work and let its lifecycle match the PHP runtime.
Code
<?php

declare(strict_types=1);

function requireEnvironmentValue(string $name): string
{
    $value = getenv($name);

    if ($value === false || $value === '') {
        throw new RuntimeException(
            sprintf('Required configuration value %s is missing.', $name)
        );
    }

    return $value;
}

/**
 * @throws RuntimeException When required configuration is invalid.
 * @throws PDOException When MySQL cannot be reached or authentication fails.
 */
function createPdoConnection(): PDO
{
    $host = requireEnvironmentValue('DB_HOST');
    $database = requireEnvironmentValue('DB_NAME');
    $username = requireEnvironmentValue('DB_USER');
    $password = requireEnvironmentValue('DB_PASSWORD');
    $portValue = getenv('DB_PORT');
    $portValue = $portValue === false || $portValue === '' ? '3306' : $portValue;

    $port = filter_var(
        $portValue,
        FILTER_VALIDATE_INT,
        [
            'options' => [
                'min_range' => 1,
                'max_range' => 65535,
            ],
        ]
    );

    if ($port === false) {
        throw new RuntimeException('The database port configuration is invalid.');
    }

    $dsn = sprintf(
        'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
        $host,
        $port,
        $database
    );

    return new PDO(
        $dsn,
        $username,
        $password,
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]
    );
}

try {
    $pdo = createPdoConnection();

    // The application can now pass $pdo to the code that performs database work.
} catch (RuntimeException | PDOException $exception) {
    $errorId = bin2hex(random_bytes(8));

    error_log(sprintf(
        'Database connection setup failed. error_id=%s exception=%s code=%s',
        $errorId,
        $exception::class,
        (string) $exception->getCode()
    ));

    http_response_code(503);
    header('Content-Type: application/json; charset=utf-8');

    echo json_encode(
        [
            'error' => 'Service temporarily unavailable.',
            'errorId' => $errorId,
        ],
        JSON_THROW_ON_ERROR
    );
}
Why Interviewers Ask This

Interviewers ask this to verify that the candidate can create a correct PDO connection, configure Unicode handling, protect credentials, select appropriate PDO options, distinguish connection setup from query execution, and handle failures without exposing passwords, database names, host details, or raw driver messages.

Common interview mistakes

Common mistakes include hard-coding credentials, committing secrets to version control, omitting charset=utf8mb4, displaying raw PDOException messages, logging the password or full DSN, using silent error handling, and creating a new connection for every query. Other mistakes include claiming prepared statements protect dynamic identifiers, enabling persistent connections without testing the runtime and connection limits, retrying authentication or configuration errors repeatedly, assuming a long-running connection never becomes stale, and adding TLS options without correctly configuring certificate verification.

Interview tip

Answer in a clear order: protected credentials, DSN, utf8mb4, PDO options, exception handling, safe logging, and connection lifecycle. State that native prepared statements help with later value binding but do not make dynamic identifiers safe. Mention TLS and reconnection only as deployment-specific considerations.

Interviewer may ask next
Why should utf8mb4 be included in the PDO MySQL DSN?

utf8mb4 supports the full Unicode range, including four-byte characters. Setting it in the DSN establishes the connection character set immediately and helps prevent corrupted text, failed writes, and inconsistent character conversion.

Should a PHP application enable persistent PDO connections?

Not by default. Persistent connections can reduce repeated connection setup in some runtimes, but they can retain session state, consume MySQL connection capacity, and behave differently across deployment models. Enable them only after workload testing, connection-limit planning, session-state control, and measurement show a real benefit.

54. How do you fetch rows from a MySQL result set in PHP?Sql / DatabaseEasy

Question Details

Explain common PDO fetch modes, fetching one row versus all rows, associative versus object results, memory considerations, and handling an empty result.

Short Interview Answer (30-60 seconds)

With PDO, I use fetch() for one row or row-by-row processing and fetchAll() only for a small complete result. I normally use PDO::FETCH_ASSOC for named columns or PDO::FETCH_OBJ for objects, and I check fetch() with === false.

Detailed Explanation

See the Code while reading this explanation.

This question asks how a PHP program reads records returned by MySQL. The main choice is whether to read one record, process records one by one, or load the complete result into memory. It also asks whether each record should be represented as a named array or an object. A complete answer should explain what each fetching method returns when no record exists, why strict checks matter, and when loading every record can waste memory. The goal is to choose a clear result shape without using more application memory than the task requires.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Is the query expected to return one row, a small collection, or a large result?
  • Does the caller need associative arrays, anonymous objects, or mapped class instances?
  • Is finding no matching row a normal outcome for this operation?
How to Explain It in an Interview

With PDO, prepare and execute the SQL statement first. Then choose the fetching method based on how many rows the application expects and how it will process them.

Use fetch() to retrieve the next row from the result set. It is appropriate when the query should return one row or when the application should process rows one at a time. PDO returns the row in the selected fetch mode. When no next row is available, fetch() returns false, so the safe check is $row === false. A loose check such as if (!$row) is less precise because strict comparison clearly separates the no-row result from valid row data. ([php.net](https://www.php.net/manual/en/pdostatement.fetch.php))

Use fetchAll() when the application needs all remaining rows and the result is known to be reasonably small. It returns an array containing every remaining row. If there are no rows to fetch, it returns an empty array. Because it builds the complete PHP result structure at once, it can place heavy demand on memory and other resources for a large result. Filtering, sorting, limiting, and aggregating in SQL can reduce the amount of data transferred to PHP. ([php.net](https://www.php.net/manual/en/pdostatement.fetchall.php))

Common fetch modes are:

  • PDO::FETCH_ASSOC: Returns an associative array indexed only by column names, such as $row['email']. This is usually a clear default for application and repository code.
  • PDO::FETCH_OBJ: Returns an anonymous stdClass object, such as $row->email. It provides property syntax but does not create a validated domain object.
  • PDO::FETCH_NUM: Returns a numerically indexed array, such as $row[0]. It can be compact, but the code depends on the selected column order.
  • PDO::FETCH_BOTH: Returns both column-name keys and numeric keys. It is PDO's default fetch mode, but the duplicated access paths are usually unnecessary.
  • PDO::FETCH_COLUMN: Returns one column from the next row when used with fetch(), or an array of one column's values when used with fetchAll().
  • PDO::FETCH_CLASS: Creates instances of a specified class and maps result columns to properties. Constructor order, property visibility, property types, and untrusted or unexpected column names require careful design.

PDO officially defines FETCH_ASSOC, FETCH_NUM, FETCH_BOTH, FETCH_COLUMN, FETCH_OBJ, and FETCH_CLASS for these result shapes. The connection-level default can be set with PDO::ATTR_DEFAULT_FETCH_MODE, while a statement-specific default can be set with PDOStatement::setFetchMode(). ([php.net](https://www.php.net/manual/en/pdo.constants.fetch-modes.php))

For one expected row, call fetch(PDO::FETCH_ASSOC) and handle false. For a small collection, call fetchAll(PDO::FETCH_ASSOC) and handle an empty array. For a potentially large collection, repeatedly call fetch() and process each row before fetching the next one.

Row-by-row fetching prevents the application from constructing one large PHP array containing every row. However, with PDO MySQL, statements are buffered by default, so the driver may already hold the result on the client side. Therefore, row-by-row fetching reduces the PHP data-structure cost but does not always guarantee constant total client memory. PHP 8.5 deprecates the old PDO::MYSQL_ATTR_USE_BUFFERED_QUERY alias in favor of Pdo\Mysql::ATTR_USE_BUFFERED_QUERY. Unbuffered mode can reduce client buffering, but the result must be fully consumed or closed before another statement is executed on the same connection. ([php.net](https://www.php.net/manual/en/ref.pdo-mysql.php))

An empty result is normally a successful query that matched no rows, not a database error. Use PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION so preparation or execution failures raise PDOException. Treat the no-row result according to the application requirement, such as returning null, showing a not-found response, or continuing with an empty list.

Select only the required columns instead of relying on SELECT *. Use prepared statements and bound values for untrusted data. Fetch modes only control how returned rows are represented; they do not make SQL construction safe.

Technical Approach
  1. Create the PDO connection with exception-based error handling and an explicit default fetch mode.
  2. Prepare the SQL statement.
  3. Pass untrusted values through placeholders rather than interpolating them into SQL.
  4. Execute the statement.
  5. Use fetch() for one row or row-by-row processing.
  6. Use fetchAll() only when all remaining rows are needed and the result is bounded.
  7. Select an explicit fetch mode that matches the caller's expected data shape.
  8. Check fetch() with === false or compare a fetchAll() result with an empty array.
  9. Process the no-row case separately from database exceptions.
  10. For very large MySQL results, consider whether buffered or unbuffered behavior is appropriate for the connection lifecycle.
Practical Insights

Reading n returned rows takes time proportional to n because each row must be transferred and converted into PHP values. fetchAll() also uses application memory proportional to the complete remaining result because it creates one large array. A fetch() loop avoids that large PHP array and usually keeps only the current processed row in application variables, but PDO MySQL buffering can still keep result data in client memory. Associative arrays store column-name keys, numeric arrays store numeric positions, and FETCH_BOTH exposes both forms, so their memory costs differ. Database cost also depends on the SQL, indexes, selected columns, matched rows, sorting, network transfer, and driver buffering.

Code
<?php

declare(strict_types=1);

$dsn = 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4';

$pdo = new PDO(
    $dsn,
    'app_user',
    'app_password',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

// Fetch one expected row.
$userStatement = $pdo->prepare(
    'SELECT id, name, email
     FROM users
     WHERE id = :id'
);
$userStatement->execute(['id' => 42]);

$user = $userStatement->fetch(PDO::FETCH_ASSOC);

if ($user === false) {
    echo "User not found.\n";
} else {
    echo $user['name'] . "\n";
}

// Fetch all rows only when the result is known to be bounded.
$statusStatement = $pdo->prepare(
    'SELECT id, label
     FROM statuses
     WHERE is_active = :is_active
     ORDER BY label'
);
$statusStatement->execute(['is_active' => 1]);

$statuses = $statusStatement->fetchAll(PDO::FETCH_ASSOC);

if ($statuses === []) {
    echo "No active statuses found.\n";
} else {
    foreach ($statuses as $status) {
        echo $status['label'] . "\n";
    }
}

// Process a potentially large result one row at a time.
$logStatement = $pdo->query(
    'SELECT id, message
     FROM audit_logs
     ORDER BY id'
);

while (($log = $logStatement->fetch(PDO::FETCH_OBJ)) !== false) {
    echo $log->id . ': ' . $log->message . "\n";
}
Why Interviewers Ask This

Interviewers want to verify that the candidate understands how PDO returns query results, how fetch modes change the result shape, how to distinguish one-row fetching from all-row fetching, how to handle an empty result correctly, and how to make a sensible memory decision for large result sets.

Common interview mistakes

Common mistakes include using fetchAll() for an unbounded result and exhausting memory; assuming that a fetch() loop always eliminates PDO MySQL client buffering; checking if (!$row) instead of $row === false; treating an empty result as a database failure; forgetting that fetchAll() returns only the remaining rows if earlier rows were already fetched; relying on PDO::FETCH_BOTH when duplicate access paths are unnecessary; using numeric indexes that break when selected column order changes; assuming PDO::FETCH_OBJ creates a typed domain object; mapping arbitrary columns directly into a class without considering constructor and property behavior; selecting unused columns with SELECT *; and interpolating untrusted values into SQL instead of using placeholders.

Interview tip

Lead with the practical rule: fetch() for one row or row-by-row processing, and fetchAll() only for a bounded result. Then compare FETCH_ASSOC with FETCH_OBJ, explain the exact empty-result values, and mention that MySQL buffering limits the memory guarantee of a fetch() loop.

Interviewer may ask next
What is the difference between fetch() returning false and fetchAll() returning an empty array?

fetch() returns false when there is no next row, so use a strict === false check. fetchAll() always returns an array and returns an empty array when there are no remaining rows. Neither outcome means the SQL failed; execution errors should be handled through PDO exceptions. ([php.net](https://www.php.net/manual/en/pdostatement.fetchall.php))

Does processing rows with fetch() always keep total memory usage constant with PDO MySQL?

No. It avoids creating one large PHP array containing all rows, but PDO MySQL uses buffered statements by default, so the driver may still hold result data on the client. Unbuffered mode can reduce that buffering, but the application must consume or close the result before running another statement on the same connection. In PHP 8.5, use Pdo\Mysql::ATTR_USE_BUFFERED_QUERY; the older PDO::MYSQL_ATTR_USE_BUFFERED_QUERY name is deprecated. ([php.net](https://www.php.net/manual/en/ref.pdo-mysql.php))

55. What are prepared statements, and why should PHP applications use them?Sql / DatabaseEasy

Question Details

Explain parameter binding, separation of SQL code from data, SQL-injection prevention, repeated execution, and important limitations such as dynamic identifiers.

Short Interview Answer (30-60 seconds)

Prepared statements define fixed SQL with placeholders and send values separately. PHP applications should use them because supplied values cannot change the intended SQL structure, preventing SQL injection through those values. They also support clean repeated execution, but dynamic identifiers require a trusted allowlist.

Detailed Explanation

See the Code while reading this explanation.

This question asks how a PHP program can safely send a command and changing information to a data store. The program first defines the command with empty positions where the changing information belongs. It then supplies each item separately. Because the command and the supplied information remain separate, text entered by a person cannot secretly change the command. The same command can also be used again with different information. However, names that change the command itself, such as a field used for sorting, must be selected from a fixed trusted list.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Should I demonstrate PDO, MySQLi, or both?
  • Should I include an example of executing one statement repeatedly?
  • Should I explain how to handle dynamic column names or sort directions?
What are prepared statements, and why should PHP applications use them? diagram
How to Explain It in an Interview

A prepared statement is an SQL statement whose structure is established separately from the data values used when it runs. The SQL contains parameter markers, also called placeholders, such as :email or ?. The PHP application prepares the SQL and then supplies a complete value for every placeholder when executing the statement.

The practical decision is to use prepared statements whenever values can change, especially when any value comes from an HTTP request, form, API request, command-line argument, file, message, or another external source. The application must not concatenate or interpolate an untrusted value into SQL.

Prepared statements prevent SQL injection through bound values because the SQL structure and parameter values are handled separately. A value containing quotes, comments, operators, or other SQL-looking characters remains a data value. It is not interpreted as an additional SQL condition or command. This removes the need to construct SQL by manually quoting or escaping each value.

With PDO, the normal process is:

  1. Create the PDO connection and enable exception-based error handling.
  2. Write fixed SQL containing named or positional placeholders.
  3. Call PDO::prepare() once to obtain a PDOStatement.
  4. Supply one value for each placeholder with bindValue(), bindParam(), or the parameter array accepted by execute().
  5. Execute the statement and process the result.
  6. Reuse the same statement with new values when the SQL structure remains unchanged.

Named and positional placeholders must not be mixed in the same statement. Each placeholder represents one complete data literal. A placeholder should not be enclosed in SQL quotes because the driver handles the value. One placeholder also cannot represent a list of values. For an IN clause, the application must create the required number of placeholders and bind every list element separately.

bindValue() binds the value available at the time of the call. bindParam() binds a PHP variable by reference, so its value is read when the statement executes. Passing an array to execute() is convenient, but PDO treats values in that array as strings unless values were explicitly bound with another PDO parameter type. Explicit types such as PDO::PARAM_INT, PDO::PARAM_BOOL, PDO::PARAM_NULL, and PDO::PARAM_STR can be useful when the database or query requires clear type handling.

A prepared statement can be executed repeatedly with different parameter values. Preparing once and reusing the resulting statement avoids repeatedly constructing SQL in PHP. Native database preparation may also reduce repeated parsing or allow the driver or server to reuse statement metadata or planning work. The actual benefit depends on the PDO driver, database server, connection lifetime, statement type, and number of executions. Prepared statements should therefore be presented primarily as a correctness and security practice, not as a guaranteed performance optimization.

Prepared statements have a critical limitation: placeholders can represent data values only. They cannot bind a table name, column name, SQL keyword, operator, clause, placeholder list, or sort direction. For example, ORDER BY :column does not make a user-supplied column name safe and normally orders by a bound value rather than selecting an identifier.

When part of the SQL structure must vary, the application should map a limited user-facing choice to a hard-coded trusted SQL fragment. For example, name can map to display_name, and created can map to created_at. Only the mapped value should be placed in the SQL. Unknown choices should be rejected or replaced with a safe default. Untrusted text must never be copied directly into the SQL structure.

PDO drivers may use native prepared statements or emulated prepared statements. PDO::ATTR_EMULATE_PREPARES => false asks supported drivers to use native preparation, but PDO documentation notes that a driver may fall back to emulation when it cannot prepare a particular query natively. Support and behavior are driver-specific, so the application should test against its actual production driver and database. Correct placeholders and trusted SQL structure are required in either mode.

Prepared statements do not replace input validation, authorization, database constraints, transactions, indexes, query-plan analysis, least-privilege database accounts, safe exception handling, or secure connection management. They solve the specific problem of safely supplying data values to SQL. They do not determine whether a value is valid for the business, whether a user is allowed to perform the operation, or whether the query is efficient.

Key Insight / Why This Solution Works
  1. Keep the SQL structure fixed.
  2. Add one named or positional placeholder for each changing scalar value.
  3. Never mix named and positional placeholders in one statement.
  4. Create PDO with exception mode enabled.
  5. Prepare the statement once.
  6. Bind values with suitable PDO parameter types when type handling matters, or pass a parameter array to execute() for simple string values.
  7. Execute and fully process the result.
  8. Reuse the statement when running the same SQL structure with new values.
  9. Build variable-length placeholder lists safely when an IN clause is required.
  10. Handle identifiers, operators, and sort directions through hard-coded allowlist mappings rather than parameters.
Code
<?php

declare(strict_types=1);

$dsn = getenv('DB_DSN');
$username = getenv('DB_USER');
$password = getenv('DB_PASSWORD');

if ($dsn === false || $username === false || $password === false) {
    throw new RuntimeException('Database environment variables are missing.');
}

$pdo = new PDO(
    $dsn,
    $username,
    $password,
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

$insert = $pdo->prepare(
    'INSERT INTO users (email, display_name, is_active)
     VALUES (:email, :display_name, :is_active)'
);

$users = [
    [
        'email' => 'first@example.com',
        'display_name' => 'First User',
        'is_active' => true,
    ],
    [
        'email' => 'second@example.com',
        'display_name' => 'Second User',
        'is_active' => false,
    ],
];

foreach ($users as $user) {
    $insert->bindValue(':email', $user['email'], PDO::PARAM_STR);
    $insert->bindValue(':display_name', $user['display_name'], PDO::PARAM_STR);
    $insert->bindValue(':is_active', $user['is_active'], PDO::PARAM_BOOL);
    $insert->execute();
}

$requestedSort = $_GET['sort'] ?? 'created';
$allowedSortColumns = [
    'name' => 'display_name',
    'created' => 'created_at',
];
$sortColumn = $allowedSortColumns[$requestedSort] ?? 'created_at';

$select = $pdo->prepare(
    "SELECT id, email, display_name, created_at
     FROM users
     WHERE is_active = :is_active
     ORDER BY {$sortColumn} DESC"
);
$select->bindValue(':is_active', true, PDO::PARAM_BOOL);
$select->execute();

foreach ($select as $row) {
    echo $row['display_name'] . PHP_EOL;
}
Why Interviewers Ask This

Interviewers ask this question to verify that a PHP developer understands safe database access, parameter binding, the separation of SQL code from data, and the correct use of PDO. They also want to know whether the candidate understands repeated execution, avoids misleading performance claims, and recognizes that placeholders bind complete data values rather than identifiers or arbitrary SQL fragments.

Common interview mistakes

Common mistakes include concatenating or interpolating untrusted values into SQL, manually escaping input instead of binding it, placing quotes around placeholders, mixing named and positional placeholders, reusing one named placeholder where the driver requires unique markers, and passing more or fewer values than the statement contains. Other mistakes include trying to bind a table name, column name, operator, sort direction, or an entire IN list; assuming prepared statements provide authorization or business validation; claiming they always improve speed; ignoring driver differences between native and emulated preparation; and fetching very large result sets into memory even though parameter binding itself does not require that buffering.

Interview tip

Start with the practical rule: keep SQL fixed and bind every changing data value. Explain that this prevents values from altering SQL structure. Mention statement reuse as a possible secondary efficiency benefit, not a guaranteed speedup. Finish with the main limitation: identifiers and SQL syntax cannot be bound and must come from trusted allowlist mappings.

Interviewer may ask next
Can a prepared-statement placeholder represent a table name, column name, sort direction, or a complete list for an IN clause?

No. A placeholder represents one complete data value. It cannot represent an identifier, keyword, operator, clause, or multiple values. Dynamic identifiers and sort directions must be selected through a hard-coded allowlist. An IN clause requires one placeholder for every list element, followed by binding each value separately.

Do prepared statements always improve performance, and should PDO emulation always be disabled?

No. Reusing a prepared statement may reduce repeated parsing, planning, or metadata work, but the result depends on the driver, database, query, connection lifetime, and number of executions. Setting PDO::ATTR_EMULATE_PREPARES to false requests native preparation where supported, but PDO may fall back to emulation for a query the driver cannot prepare natively. The application should test its actual production driver and use prepared statements primarily for safe, correct value handling.

56. How do you perform a database transaction with PDO?Sql / DatabaseEasy

Question Details

Explain beginTransaction, commit, rollBack, exception handling, atomicity, and what should happen when one operation in a multi-step write fails.

Short Interview Answer (30-60 seconds)

Call beginTransaction(), run all related statements through the same PDO connection, and call commit() only when every step succeeds. On any failure, catch the error, call rollBack() if the transaction is active, and rethrow or handle the error so partial changes are not committed.

Detailed Explanation

See the Code while reading this explanation.

This question asks how to make several related changes behave as one complete action. For example, an application may create an order and add its items together. Every change should be saved only when all steps finish successfully. If any step fails, the earlier changes should be undone so the stored information is not left incomplete or incorrect. The answer should also explain how the program notices a failure, cancels unfinished work safely, and reports the problem instead of continuing as though the whole action succeeded.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Are all statements executed through the same PDO connection?
  • Do all affected tables and statements support transactions?
  • Is a particular isolation level or concurrency behavior required?
  • Should temporary failures such as deadlocks be retried?
How do you perform a database transaction with PDO? diagram
How to Explain It in an Interview

A database transaction groups related operations into one unit. In PDO, I call beginTransaction() before the first database change. I then execute every related statement through the same PDO connection. If every statement and required business check succeeds, I call commit(). Commit makes the transaction's changes permanent.

I configure PDO to report database errors as exceptions by setting PDO::ATTR_ERRMODE to PDO::ERRMODE_EXCEPTION. The transaction belongs inside a try block. If a PDO operation or an application-level validation check throws an exception, the catch block calls rollBack() when PDO::inTransaction() is true. Rollback cancels the uncommitted database changes. The application should then rethrow the original error or translate it at an appropriate boundary; it should not pretend the operation succeeded.

This provides atomicity. Atomicity means the related database changes are committed together or none of them are committed. For example, if creating an order succeeds but inserting one order item fails, rolling back also cancels the order insert.

The transaction should be kept short. Long transactions can hold locks or retain row versions and other database resources for longer. This can block competing work, increase contention, and make deadlocks more likely. Slow network calls, user interaction, file processing, and unrelated work should normally happen outside the transaction.

A transaction covers only operations performed by the participating database connection and supported transactional tables or statements. It does not automatically undo an email, file write, message publication, or external API request. Those side effects need a separate design, such as an outbox table, idempotent processing, or compensating work.

Prepared statements should bind untrusted values, but parameter binding and transactions solve different problems. Parameters protect values and improve statement handling; the transaction controls whether a group of database changes is committed. Dynamic identifiers such as table or column names cannot be made safe by binding them as parameters and should come from a trusted allowlist.

Database-specific behavior also matters. Some databases or statements can perform an implicit commit, especially certain schema-changing statements. PDO also does not provide portable nested transactions. If nested units are required, the application must use database-supported savepoints deliberately or structure the transaction ownership so only one layer begins and ends the transaction.

Technical Approach
  1. Obtain one PDO connection and enable exception mode.
  2. Validate data that does not require database locks before opening the transaction.
  3. Prepare the required statements.
  4. Call beginTransaction().
  5. Execute every related write through that same PDO connection.
  6. Check affected rows and any business conditions that must hold.
  7. Call commit() only after all required work succeeds.
  8. Catch Throwable so both database and application failures trigger cleanup.
  9. If inTransaction() is true, call rollBack().
  10. Rethrow, log, or translate the original failure at the appropriate application boundary.
  11. Retry only recognized temporary database failures, with a strict limit, when the whole operation is safe to repeat.
Practical Insights

The begin, commit, and rollback calls use very little PHP memory and add only a small amount of application work. The important cost is in the SQL statements, affected rows, indexes, constraints, logging, and locks or row versions maintained by the database. A longer transaction can delay other requests and increase contention or deadlock risk. Additional indexes can make writes slower because each affected index must be maintained. Retry logic and coordination with external side effects increase operational and maintenance complexity.

Code
<?php

declare(strict_types=1);

$pdo = new PDO(
    'mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4',
    'app_user',
    'app_password',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

$customerId = 42;
$items = [
    ['product_id' => 101, 'quantity' => 2, 'unit_price' => '19.99'],
    ['product_id' => 205, 'quantity' => 1, 'unit_price' => '8.50'],
];

$insertOrder = $pdo->prepare(
    'INSERT INTO orders (customer_id, status)
     VALUES (:customer_id, :status)'
);

$insertItem = $pdo->prepare(
    'INSERT INTO order_items
        (order_id, product_id, quantity, unit_price)
     VALUES
        (:order_id, :product_id, :quantity, :unit_price)'
);

try {
    $pdo->beginTransaction();

    $insertOrder->execute([
        'customer_id' => $customerId,
        'status' => 'pending',
    ]);

    $orderId = (int) $pdo->lastInsertId();

    foreach ($items as $item) {
        if ($item['quantity'] <= 0) {
            throw new InvalidArgumentException(
                'Quantity must be greater than zero.'
            );
        }

        $insertItem->execute([
            'order_id' => $orderId,
            'product_id' => $item['product_id'],
            'quantity' => $item['quantity'],
            'unit_price' => $item['unit_price'],
        ]);
    }

    $pdo->commit();
} catch (Throwable $error) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    throw $error;
}
Why Interviewers Ask This

Interviewers want to confirm that the candidate can keep related database writes consistent. A strong answer demonstrates correct PDO transaction boundaries, exception handling, rollback behavior, use of a single connection, and an understanding of atomicity when one step in a multi-step operation fails.

Common interview mistakes

Common mistakes include committing before all required operations succeed, forgetting to roll back after a failure, running part of the work through another connection, swallowing the exception and reporting success, opening a transaction before slow unrelated work, assuming rollback reverses external side effects, using non-transactional tables or statements without checking database behavior, attempting unsupported nested transactions, interpolating untrusted values into SQL, and retrying every error without limiting retries or making the operation safe to repeat.

Interview tip

Explain the path in order: begin, execute, validate, commit, catch, rollback, and rethrow. Define atomicity in one sentence, state that all statements must use the same connection, and mention one production tradeoff: keep the transaction short to reduce contention and deadlock risk.

Interviewer may ask next
Why should you check inTransaction() before calling rollBack()?

A failure can happen before the transaction starts, after it has already ended, or while commit is being attempted. Calling rollBack() without an active transaction can throw another exception and obscure the original problem. inTransaction() reduces that risk by confirming that PDO still reports an active transaction.

Should a PDO transaction automatically be retried after a deadlock?

Only recognized temporary failures, such as a deadlock or serialization failure, should be retried. Restart the entire transaction from the beginning, use a small retry limit with delay or backoff, and retry only when repeating the operation cannot create duplicate external side effects.

57. How do transaction isolation levels affect concurrent PHP requests?Sql / DatabaseHard

Question Details

Compare dirty reads, non-repeatable reads, phantom reads, write conflicts, and practical tradeoffs among common isolation levels for a web application.

Short Interview Answer (30-60 seconds)

Isolation levels control what concurrent transactions can observe and how conflicts are handled. Lower levels permit more changing results. Higher levels provide stronger guarantees but may increase blocking or aborted transactions. PHP code should also use atomic SQL, constraints, short transactions, and bounded full-transaction retries.

Detailed Explanation

This question asks what can happen when several website requests read or change the same information at nearly the same time. One request may see work that is later cancelled, get different answers when reading twice, miss newly added items, or compete with another request changing the same information. The candidate should explain how stricter rules reduce these surprises while sometimes causing more waiting or requiring a request to try again. The best choice depends on how serious an incorrect result would be and how much waiting the application can accept.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Which database engine and storage engine are being used?
  • Are the requests mainly reading, writing, or doing both?
  • Must repeated reads inside one transaction remain stable?
  • Which business rule must remain true under concurrency?
  • Can the application safely retry the complete transaction?
How do transaction isolation levels affect concurrent PHP requests? diagram
How to Explain It in an Interview

A transaction is a group of database operations that commits as one unit or rolls back as one unit. Concurrent PHP requests normally use separate database sessions and may run transactions at the same time. The isolation level determines which effects of other transactions are visible and how the database handles certain conflicts.

The SQL standard defines minimum guarantees for isolation levels, but exact behavior varies by database engine. Some engines mainly use locks, some use multi-version concurrency control, and many use both. Therefore, I would identify the database before relying on implementation-specific behavior.

Dirty reads

A dirty read occurs when one transaction reads a value written by another transaction that has not committed. If the writer later rolls back, the reader used a value that never became permanent.

Under the SQL standard, READ UNCOMMITTED may permit dirty reads. Some database engines still prevent true dirty reads at this level or treat it like READ COMMITTED. Dirty reads are rarely acceptable for balances, inventory, permissions, order states, or other business decisions.

Non-repeatable reads

A non-repeatable read occurs when one transaction reads the same row twice and receives different committed values because another transaction updated or deleted that row between the reads.

At READ COMMITTED, each statement commonly sees data committed before that statement begins. A PHP transaction can therefore read one value and later receive a different value for the same row.

This may be acceptable for short independent queries. It is unsafe when application logic assumes an earlier value remains unchanged before making a later decision or write.

Phantom reads

A phantom read occurs when one transaction repeats a condition-based query and receives a different set of matching rows because another transaction inserted, deleted, or changed rows between the two queries.

For example, one request counts pending orders twice while another request inserts a new pending order. At an isolation level that permits phantoms, the second query may contain an additional row.

The SQL standard requires phantom prevention at SERIALIZABLE. Practical behavior at REPEATABLE READ differs among engines. Snapshot-based ordinary reads may remain stable, while locking reads, writes, and predicate protection may follow different rules.

Write conflicts and lost updates

Read anomalies are not the only concern. Two PHP requests may update the same row, reserve the same item, or enforce a rule involving several rows.

A direct write conflict may cause one transaction to wait, deadlock, receive a serialization failure, or fail with another database-specific error. An unsafe read-modify-write sequence may also produce a lost update, where one request overwrites another request's result.

For example:

1. Request A reads stock as 10. 2. Request B reads stock as 10. 3. Both calculate a new value independently. 4. Both write their calculated value.

Depending on the SQL and database behavior, one write may overwrite the effect of the other.

A safer approach is an atomic conditional update that reduces stock only when enough stock remains, followed by checking the affected-row count. Other protections include row locking, optimistic version checks, and database constraints.

Write skew

Snapshot-based isolation can allow write skew even when each transaction sees a stable snapshot. Two transactions read the same multi-row condition, update different rows, and together violate a business rule.

For example, two requests both see that two staff members are on call. Each request independently removes a different staff member, leaving nobody on call. Because the transactions update different rows, a simple same-row write conflict may not occur.

A serializable transaction, targeted locking, an enforceable constraint, or a redesigned data model may be required for this rule.

Common isolation levels
READ UNCOMMITTED

This is the weakest standard level. Dirty reads, non-repeatable reads, and phantom reads may occur under the standard model.

It is rarely appropriate for PHP business workflows. The exact database implementation still matters because some engines provide stronger behavior than the standard minimum.

READ COMMITTED

Dirty reads are prevented. Separate statements in the same transaction may observe newly committed changes, so non-repeatable reads and phantom reads may occur.

This is a practical default for many short web transactions because it usually provides good concurrency. Application code must not assume that an earlier read remains current. Important writes should use atomic SQL, constraints, explicit locks, or optimistic concurrency checks.

REPEATABLE READ

The SQL standard prevents dirty reads and non-repeatable reads at this level, but it does not require the same phantom protection as SERIALIZABLE. Actual implementations differ substantially.

A multi-version database may provide one stable snapshot for ordinary reads. That does not automatically prevent every lost update, write skew, deadlock, or conflicting write. Locking reads may also behave differently from ordinary snapshot reads.

This level is useful when several related reads should use one consistent view, but the application must understand whether that view can become stale relative to concurrent commits.

SERIALIZABLE

SERIALIZABLE provides the strongest standard isolation. The committed result must be equivalent to transactions running in some valid serial order, even if the database executes them concurrently.

A database may enforce this by blocking operations, using row, range, or predicate locks, detecting dangerous dependency patterns, or aborting a transaction. Serializable isolation therefore does not mean every request succeeds immediately. PHP code must be prepared for deadlocks or serialization failures and may need to retry the complete transaction.

Choosing the practical level

For ordinary short create, read, update, and delete requests, READ COMMITTED is often a reasonable starting point when combined with atomic SQL and database constraints.

Use REPEATABLE READ when several related reads must use a stable view and the selected database's exact semantics fit the workflow.

Use SERIALIZABLE when correctness depends on a multi-row or predicate-based rule that cannot be protected reliably with a simpler atomic statement, constraint, optimistic version check, or targeted lock.

Do not automatically choose the strongest level for every request. Stronger isolation can increase waiting, aborted transactions, lock contention, retained row versions, cleanup work, and retry cost. In lock-based systems it can also increase deadlock opportunities. Under high contention, it may reduce throughput.

Do not choose weaker isolation only for speed. Incorrect inventory, duplicate redemption, or invalid account state may cost far more than a properly designed transaction.

Database protections that complement isolation

Isolation should be combined with features that express the business rule directly:

  • Use atomic conditional updates for counters, balances, and inventory changes.
  • Use unique constraints to prevent duplicate identifiers or one-time claims.
  • Use foreign-key and check constraints for relationships and row-level rules.
  • Use SELECT ... FOR UPDATE or the database's equivalent when specific rows must be locked before related writes.
  • Use a version column or expected previous value for optimistic concurrency.
  • Use indexes that support important search and locking predicates.

Prepared statements and parameter binding protect data values from SQL injection. They do not provide transaction isolation and do not prevent concurrency anomalies. Dynamic identifiers cannot be made safe merely by binding them as parameters; they must be selected from a trusted allowlist and quoted using database-specific rules when necessary.

PHP and PDO responsibilities

PDO does not define one universal isolation implementation. The selected PDO driver sends transaction and SQL commands to the database, and the database engine supplies the behavior.

A PHP request should:

  1. Use the same PDO connection for the entire transaction.
  2. Configure the required isolation level using syntax supported by that database and at the time required by that database.
  3. Begin the transaction immediately before the protected database work.
  4. Keep HTTP calls, file operations, user interaction, and slow computation outside the transaction.
  5. Commit only after every required statement succeeds.
  6. Roll back after an exception or validation failure.
  7. Recognize retryable failures through database-specific SQLSTATE values or driver error codes.
  8. Retry the entire transaction, not only the failed statement.
  9. Use a small retry limit and backoff to avoid retry storms.
  10. Prevent external side effects from being duplicated during retries.

PDO::beginTransaction() starts a transaction but does not itself select the desired isolation level. Isolation must be configured according to the database's supported commands and connection rules.

A retry must re-run all reads and writes because the database state may have changed. Retrying only the failed statement can make it inconsistent with decisions based on earlier reads.

Connection lifecycle

Isolation settings can be transaction-scoped or session-scoped depending on the database and command used. Persistent PDO connections, long-running PHP workers, connection pools, and database proxies may reuse a session. Session-level changes can therefore affect later work if they are not reset.

Traditional request-based PHP deployments often release ordinary non-persistent connections at the end of a request. Applications should still understand their actual connection lifecycle rather than assuming every request always receives a completely new database session.

Performance, storage, and memory tradeoffs

Isolation normally does not change the formal Big O complexity of the business algorithm. An indexed lookup remains an indexed lookup. However, it can significantly change operational cost.

Lock-based implementations may make transactions wait and can create deadlocks when transactions acquire incompatible locks in different orders. Multi-version implementations may retain older row versions while transactions or snapshots remain active. Those versions are normally stored and managed by the database, not in PHP application memory, but they can increase database storage, cleanup, vacuum, undo-log, or version-chain work depending on the engine.

Serializable implementations may block transactions or abort transactions that would complete at a weaker level. Retries consume additional database work, PHP execution time, and connection capacity.

Long transactions make these costs worse. Good indexes reduce unnecessary scanning and may narrow the rows or key ranges involved, but they do not guarantee that blocking, phantom protection, deadlocks, or serialization failures will disappear.

Technical Approach
  1. Identify the exact business invariant that concurrent requests must preserve.
  2. Determine whether the risk is a dirty read, non-repeatable read, phantom, lost update, write skew, duplicate creation, or direct write conflict.
  3. Confirm the database engine and its exact isolation semantics.
  4. Prefer an atomic SQL statement or database constraint when it can express the rule.
  5. Add optimistic concurrency or targeted row locking when appropriate.
  6. Choose the weakest isolation level that still preserves correctness.
  7. Keep the transaction short and use supporting indexes.
  8. Roll back on failure.
  9. Retry the complete transaction only for recognized transient database errors, using a strict attempt limit and backoff.
  10. Test the workflow with genuinely concurrent requests.
Practical Insights

Isolation usually does not change the formal time or memory complexity of the business algorithm. Its main costs are operational. Locks can make requests wait. Deadlocks and serialization failures can force complete retries. Multi-version databases may retain older row versions, increasing database storage and cleanup work rather than PHP heap memory. Stronger isolation can reduce throughput when many requests compete for the same data. Good indexes reduce unnecessary scanning and may reduce contention, but they do not eliminate every conflict. Retry handling, database-specific error detection, idempotency, and connection-state cleanup also increase implementation and maintenance cost.

Why Interviewers Ask This

Interviewers want to verify that the candidate understands how concurrent web requests interact through a database, can distinguish dirty reads, non-repeatable reads, phantom reads, lost updates, write conflicts, and write skew, and can choose practical protections without assuming that isolation alone solves every consistency problem.

Common interview mistakes

Common mistakes include assuming PHP requests execute one at a time; assuming READ COMMITTED prevents lost updates; describing all databases as having identical isolation behavior; confusing a stable snapshot with serializable execution; ignoring write skew; using a read followed by an unconditional write; relying only on application checks instead of database constraints; using SELECT ... FOR UPDATE outside a suitable transaction; holding transactions open during HTTP calls; changing isolation at an invalid point; omitting indexes for important predicates; treating every database exception as retryable; retrying only the failed statement; retrying without a limit; duplicating an external side effect during a retry; assuming prepared statements prevent concurrency problems; and assuming parameter binding makes dynamic table or column identifiers safe.

Interview tip

Start with the practical rule: choose the weakest isolation level that still protects the business invariant. Define each read anomaly, discuss write conflicts and write skew separately, acknowledge database-specific behavior, and finish with atomic SQL, constraints, short transactions, and bounded full-transaction retries.

Interviewer may ask next
How should a PHP application handle a deadlock or serialization failure?

It should roll back, inspect the database-specific SQLSTATE or driver error code, and retry only when the error is known to be transient. The application must start a new transaction and repeat every read and write because earlier decisions may no longer be valid. Retries need a small limit and backoff, and any external side effect must be idempotent, deferred, or coordinated so that it is not duplicated.

Does SERIALIZABLE remove the need for constraints, locks, or atomic updates?

No. Serializable isolation controls the outcome of concurrent transactions, but constraints remain the final protection for enforceable data rules. Atomic updates are often simpler and create less contention. Targeted locks, optimistic checks, or data-model changes may also express the rule more clearly. Serializable isolation is most useful when a multi-row or predicate-based invariant cannot be protected reliably by those simpler mechanisms.

58. How do you implement pagination safely and efficiently in a PHP application?Sql / DatabaseMedium

Question Details

Compare LIMIT/OFFSET pagination with keyset pagination, define stable ordering, bind pagination values safely, and discuss behavior when rows are inserted or deleted between requests.

Short Interview Answer (30-60 seconds)

I use LIMIT/OFFSET for small lists that require direct page-number navigation. For large or changing datasets, I prefer keyset pagination with a unique indexed order such as created_at and id. I validate limits, bind values with PDO, and create the next cursor from the final returned row.

Detailed Explanation

See the Code while reading this explanation.

Pagination returns a large list in smaller groups. The application must keep the order predictable, prevent unsafe input, and decide where each new group begins. A simple page number works well for short lists, but later pages can become slower and changing records can move between groups. A saved position works better for long or frequently changing lists, although it cannot easily jump to any numbered page. The correct design depends on how users navigate, how often the list changes, and whether every request must represent the same frozen view of the data.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Does the interface require direct jumps to page numbers, or only next and previous navigation?
  • How large can the result set become, and how deeply do users normally browse?
  • Can rows be inserted, deleted, or have their ordering values updated while a user is paging?
  • Which columns define the required business order?
  • Is an exact total row count required on every request?
How do you implement pagination safely and efficiently in a PHP application? diagram
How to Explain It in an Interview

I start by defining deterministic ordering. Deterministic ordering means every row has one predictable position. Ordering only by a non-unique value such as created_at is insufficient because multiple rows can share the same timestamp. I add a unique tie-breaker, normally the primary key:

ORDER BY created_at DESC, id DESC

For MySQL 8 or later, a matching index can be created as follows:

CREATE INDEX idx_articles_pagination ON articles (created_at DESC, id DESC);

Whether the optimizer uses that index also depends on filters, selected columns, table statistics, and the query plan, so I verify important queries with EXPLAIN rather than assuming the index will always be chosen.

For a small result set or an interface that requires numbered pages, LIMIT/OFFSET is reasonable:

SELECT id, title, created_at FROM articles ORDER BY created_at DESC, id DESC LIMIT :limit OFFSET :offset;

The PHP application validates the page number and page size before calculating the offset. It should reject or cap values that could overflow the application's integer range or cause unreasonable database work. LIMIT and OFFSET are data values in this fixed statement, so they can be bound as PDO::PARAM_INT when the database driver supports placeholders there. Dynamic table names, column names, and sort directions are identifiers or SQL syntax, not data values. Prepared statements do not make them safe; they must be selected from a fixed server-side allowlist.

LIMIT/OFFSET has two important limitations. First, the database still has to process or skip rows before the requested offset, so deep pages can become increasingly expensive. The exact cost depends on the execution plan and available indexes; it is not correct to promise a fixed complexity for every database and query. Second, offsets describe positions rather than row identities. If a new row is inserted before the next offset, a row already seen can shift into the next page and appear again. If an earlier row is deleted, an unseen row can shift into an already skipped position and be missed.

For large feeds, histories, logs, or frequently changing result sets, I prefer keyset pagination, also called seek or cursor pagination. The cursor contains the complete ordering values from the final row returned to the client. With descending created_at and id ordering, the next-page query is:

SELECT id, title, created_at FROM articles WHERE created_at < :created_at_before OR (created_at = :created_at_equal AND id < :id) ORDER BY created_at DESC, id DESC LIMIT :limit;

The two created_at placeholders are intentionally different. With native PDO prepared statements, a named placeholder should not be reused in the same statement. Both placeholders are bound to the same cursor value.

When the composite index and predicates fit the query, the database can begin near the cursor boundary instead of repeatedly skipping every earlier result. This usually makes the amount of work per page much more stable than a deep offset, but filters, joins, sorting, data distribution, and optimizer choices can still change the actual plan. I confirm production behavior with EXPLAIN and representative data.

Keyset pagination behaves more predictably when rows are inserted before the cursor. Those new rows do not move the saved boundary, so they do not normally cause the offset-style duplicate on subsequent pages. A new row that sorts after the boundary can appear on a later page. A deleted row simply cannot be returned. However, if an existing row's created_at or other ordering value is updated, it can move across the boundary and may be skipped or seen again. An immutable ordering value reduces this risk.

A cursor is client input, so I decode it, validate its structure and types, limit its length, and bind every value. Encoding a cursor with Base64 makes it opaque-looking but does not prevent modification. When cursor values must not be changed, I sign the serialized payload with an HMAC using a server-side secret and verify the signature before querying. Authorization must still be enforced independently; a signed cursor must not grant access to rows the current user cannot view.

Each HTTP page request normally uses its own short database transaction. A transaction or repeatable-read snapshot from one request does not automatically continue into the next request after the connection and transaction end. Keeping one transaction open while a user browses multiple pages is usually impractical and can retain database resources or old row versions. If the product requires every page to represent one frozen result set, I use an explicit snapshot design, such as immutable versioned data, a materialized result, or stored matching identifiers with an expiration policy.

Keyset pagination cannot efficiently jump directly to arbitrary page 500 because that page's starting cursor is not known. It is therefore best for next-page or previous-page navigation. LIMIT/OFFSET remains appropriate when direct page jumps are a real requirement and expected offsets remain controlled. The production choice should be based on navigation needs, data volatility, measured query plans, and acceptable consistency behavior.

Key Insight / Why This Solution Works
  1. Define the required business order and add a unique tie-breaker so the order is deterministic.
  2. Create and test a composite index that matches the filtering and ordering pattern.
  3. Set a server-side minimum and maximum page size.
  4. For LIMIT/OFFSET, validate the page number before calculating the offset and reject values that overflow or exceed an allowed depth.
  5. Use LIMIT/OFFSET for small or shallow lists that genuinely require direct page-number navigation.
  6. Use keyset pagination for large or frequently changing lists with sequential navigation.
  7. Decode the cursor, limit its encoded length, validate every field, and verify its signature when tamper resistance is required.
  8. Use a fixed SQL statement and bind all cursor, limit, and offset data values with appropriate PDO types.
  9. Fetch page size plus one row to determine whether another page exists.
  10. Remove the look-ahead row and create the next cursor from the last row actually returned.
  11. Use EXPLAIN with representative data to confirm index access, examined rows, and sorting behavior.
  12. Document how inserts, deletions, and updates to ordering columns affect navigation.
Code
<?php

declare(strict_types=1);

/**
 * @param array{created_at: string, id: int} $payload
 */
function encodeCursor(array $payload): string
{
    $json = json_encode($payload, JSON_THROW_ON_ERROR);

    return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}

/**
 * @return array{created_at: string, id: int}
 */
function decodeCursor(string $cursor): array
{
    if (strlen($cursor) > 512 || !preg_match('/^[A-Za-z0-9_-]+$/', $cursor)) {
        throw new InvalidArgumentException('Invalid cursor format.');
    }

    $paddingLength = (4 - strlen($cursor) % 4) % 4;
    $base64 = strtr($cursor . str_repeat('=', $paddingLength), '-_', '+/');
    $decoded = base64_decode($base64, true);

    if ($decoded === false) {
        throw new InvalidArgumentException('Invalid cursor encoding.');
    }

    $data = json_decode($decoded, true, 16, JSON_THROW_ON_ERROR);

    if (!is_array($data) || array_keys($data) !== ['created_at', 'id']) {
        throw new InvalidArgumentException('Invalid cursor data.');
    }

    if (
        !is_string($data['created_at'])
        || preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{1,6})?$/', $data['created_at']) !== 1
    ) {
        throw new InvalidArgumentException('Invalid cursor timestamp.');
    }

    $id = filter_var($data['id'], FILTER_VALIDATE_INT);
    if ($id === false || $id < 1) {
        throw new InvalidArgumentException('Invalid cursor id.');
    }

    return [
        'created_at' => $data['created_at'],
        'id' => $id,
    ];
}

/**
 * @return array{
 *     items: list<array{id: int, title: string, created_at: string}>,
 *     next_cursor: ?string
 * }
 */
function fetchArticlePage(PDO $pdo, int $requestedLimit, ?string $cursor): array
{
    $limit = max(1, min($requestedLimit, 100));
    $fetchLimit = $limit + 1;

    if ($cursor === null) {
        $sql = <<<'SQL'
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT :limit
SQL;

        $statement = $pdo->prepare($sql);
        $statement->bindValue(':limit', $fetchLimit, PDO::PARAM_INT);
    } else {
        $boundary = decodeCursor($cursor);

        $sql = <<<'SQL'
SELECT id, title, created_at
FROM articles
WHERE created_at < :created_at_before
   OR (created_at = :created_at_equal AND id < :id)
ORDER BY created_at DESC, id DESC
LIMIT :limit
SQL;

        $statement = $pdo->prepare($sql);
        $statement->bindValue(':created_at_before', $boundary['created_at'], PDO::PARAM_STR);
        $statement->bindValue(':created_at_equal', $boundary['created_at'], PDO::PARAM_STR);
        $statement->bindValue(':id', $boundary['id'], PDO::PARAM_INT);
        $statement->bindValue(':limit', $fetchLimit, PDO::PARAM_INT);
    }

    $statement->execute();
    $rows = $statement->fetchAll(PDO::FETCH_ASSOC);

    $hasMore = count($rows) > $limit;
    if ($hasMore) {
        array_pop($rows);
    }

    $items = array_map(
        static fn(array $row): array => [
            'id' => (int) $row['id'],
            'title' => (string) $row['title'],
            'created_at' => (string) $row['created_at'],
        ],
        $rows
    );

    $nextCursor = null;
    if ($hasMore && $items !== []) {
        $lastItem = $items[array_key_last($items)];
        $nextCursor = encodeCursor([
            'created_at' => $lastItem['created_at'],
            'id' => $lastItem['id'],
        ]);
    }

    return [
        'items' => $items,
        'next_cursor' => $nextCursor,
    ];
}

$dsn = getenv('DATABASE_DSN') ?: 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4';
$username = getenv('DATABASE_USER') ?: 'app';
$password = getenv('DATABASE_PASSWORD') ?: '';

$pdo = new PDO($dsn, $username, $password, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

$rawLimit = filter_input(INPUT_GET, 'limit', FILTER_VALIDATE_INT);
$requestedLimit = is_int($rawLimit) ? $rawLimit : 25;

$rawCursor = filter_input(INPUT_GET, 'cursor', FILTER_UNSAFE_RAW);
$cursor = is_string($rawCursor) && $rawCursor !== '' ? $rawCursor : null;

try {
    $result = fetchArticlePage($pdo, $requestedLimit, $cursor);

    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (InvalidArgumentException | JsonException $exception) {
    http_response_code(400);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['error' => 'Invalid pagination cursor.'], JSON_THROW_ON_ERROR);
} catch (PDOException) {
    http_response_code(500);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['error' => 'Database request failed.'], JSON_THROW_ON_ERROR);
}
Why Interviewers Ask This

Interviewers want to see whether the candidate can build pagination that remains secure, predictable, and efficient as data grows or changes. The question tests deterministic SQL ordering, composite indexes, prepared statements, PDO parameter types, cursor design, query-plan awareness, and consistency across separate requests. It also tests whether the candidate understands that LIMIT/OFFSET and keyset pagination solve different navigation requirements and have different behavior when rows are inserted, deleted, or reordered.

Common interview mistakes

Common mistakes include omitting ORDER BY; ordering by a non-unique column without a unique tie-breaker; using deep offsets without measuring the plan; forgetting a matching composite index; trusting the optimizer to use an index without checking EXPLAIN; accepting unlimited page sizes or offsets; allowing page arithmetic to overflow; interpolating pagination values into SQL; treating user-provided identifiers or sort directions as bindable data; reusing the same named PDO placeholder in a native prepared statement; binding LIMIT or OFFSET with an unsuitable type; placing only id in the cursor when the query primarily orders by created_at; using comparison directions that conflict with ORDER BY; creating the next cursor from the removed look-ahead row; assuming Base64 encoding prevents cursor changes; treating a signed cursor as authorization; claiming keyset pagination creates a frozen snapshot; and ignoring updates that move rows across the cursor boundary.

Interview tip

Begin with the choice: LIMIT/OFFSET for controlled numbered pages and keyset pagination for scalable sequential navigation. Then explain deterministic ordering, the matching index, safe PDO binding, and the cursor predicate. Finish with measured query plans, insert and delete behavior, ordering-column updates, snapshot limitations, and the inability of keyset pagination to jump directly to an arbitrary page.

Interviewer may ask next
How would you paginate in ascending order when created_at is not unique?

I would use ORDER BY created_at ASC, id ASC, with id as the unique tie-breaker. The next-page predicate would be created_at > :created_at_before OR (created_at = :created_at_equal AND id > :id). The cursor would contain both values, and I would test a matching composite index and the actual execution plan.

How would you stop clients from modifying a keyset cursor?

Base64 encoding is not protection. I would serialize only the permitted cursor fields, sign the serialized payload with an HMAC using a server-side secret, and verify the signature before decoding and querying. I would still validate every field, bind every value through PDO, enforce expiration when needed, and apply normal authorization independently.

59. How would you diagnose and eliminate an N+1 query problem in a PHP application?Sql / DatabaseMedium

Question Details

Given a page that loads parent records and then issues one query per parent, explain how to detect the pattern and fix it using joins, eager loading, batching, or preloading while preserving correctness.

Short Interview Answer (30-60 seconds)

I would confirm the repeated-query pattern with query logs or a profiler, then replace per-parent queries with a join, eager loading, or a batched preload. I would preserve filtering and ordering, verify the child foreign-key index, and compare query count, database time, memory, and total response time.

Detailed Explanation

See the Code while reading this explanation.

This question describes a page that first loads a list of main records and then asks for related records separately for every item in that list. As the list becomes larger, the page sends many more requests and can become slow. I would first count those requests and find where the repeated work begins. I would then collect the related records in one combined operation or a small fixed number of operations. Finally, I would reconnect every related record to the correct main record and confirm that the page still shows the same information.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Are the parent and child records stored in the same database?
  • Is the application using PDO directly, a query builder, or an ORM?
  • Does the page need all child records or only a filtered or limited subset?
  • Must parents with no children still be returned?
  • Are pagination, ordering, authorization, or consistency requirements involved?
How would you diagnose and eliminate an N+1 query problem in a PHP application? diagram
How to Explain It in an Interview

An N+1 query problem occurs when the application executes one query to load N parent rows and then executes one additional query for each parent. For example, one query loads 100 posts and a loop executes another 100 queries to load each post's comments. The request performs 101 queries, and its database round trips grow with the number of parent rows.

I would diagnose the pattern before changing the implementation. In development or staging, I would enable query logging in the database abstraction layer, use the framework or ORM profiler when available, or add temporary instrumentation around PDO calls. I would capture the normalized SQL statement, execution count, duration, and application call location. Sensitive parameter values should not be written to logs. The typical signal is one child query repeated many times with only the parent identifier changing.

I would test with a realistic number of parent records because a small development dataset can hide the problem. I would compare query counts for different parent-page sizes. If the request consistently performs one parent query plus approximately one child query per parent, the N+1 behavior is confirmed. I would also distinguish query execution time from total request time because many individually fast queries can still be expensive due to repeated network, parsing, and application overhead.

The correct fix depends on the required result shape and relationship.

A SQL join is appropriate when the related rows can be returned together without producing an excessive result set. A LEFT JOIN preserves parents that have no matching children. Because one parent can match several children, the result repeats the parent columns. PHP or the data-mapping layer must group those rows correctly. Joining several independent one-to-many relationships in one query can multiply rows, so one large join is not automatically the best solution.

ORM eager loading means requesting a relationship before application code iterates over the parents. A typical eager-loading implementation uses one query for the parents and another query for all required children, but this behavior depends on the ORM and relationship configuration. I would inspect the generated SQL and query count rather than assume the ORM has removed the problem. I would also request only the columns and relationships needed by the page.

With PDO, a practical solution is two-query preloading. I would load the current page of parents, collect their identifiers, and execute one child query using an IN list containing one bound placeholder per identifier. I would group the child rows by their parent identifier in PHP and attach each group to the corresponding parent. Parents with no children would receive an empty collection.

PDO cannot bind an array of identifiers to one placeholder and automatically expand it into an IN list. The application must generate the required number of placeholder tokens and bind each value separately. The placeholder names are application-generated SQL syntax, while every identifier remains a bound value. Untrusted values must never be concatenated into the SQL statement. Prepared statements protect values, not dynamically supplied table names, column names, sort directions, or other identifiers.

Pagination should normally be applied to the parent query before preloading children. Otherwise, the application may fetch child records for parents that are not displayed. The parent ordering should be deterministic, especially for pagination, and the child query should preserve any required filtering and ordering. If only a count, latest child, or limited subset is needed, I would query that exact shape instead of loading every child row.

For a large parent set, I would avoid assuming that one enormous IN list is efficient or even accepted by every database configuration. I would use bounded batches, test an appropriate batch size, and merge the grouped results. Depending on the database and workload, alternatives can include a join, a derived table, a temporary table, or another database-supported bulk-input method. The choice should be based on the query plan and measured behavior rather than a universal batch-size rule.

I would verify that the child table has an index suitable for the lookup. For a query filtering by child.parent_id, an index with parent_id as its leading column is normally important. If the query also filters or orders by other columns, a composite index may be useful, but it should be designed for the actual query and write workload. I would inspect the database query plan to confirm whether rows are being located efficiently instead of claiming that the presence of an index guarantees its use.

A read transaction is not automatically required. The two SELECT statements may observe different committed states if another transaction changes related data between them, depending on the database and isolation behavior. If the page requires one consistent view of the parents and children, I would use a short read transaction with an isolation level that provides the required snapshot semantics for the selected database. I would not hold that transaction open while rendering the response or calling external services.

After applying the fix, I would test both performance and correctness. I would verify parents with no children, multiple children, duplicate-looking values, null fields, child ordering, parent pagination, filters, authorization rules, and concurrent changes when consistency matters. I would compare query count, total database time, rows returned, data transferred, peak PHP memory, and end-to-end response time. The goal is not merely fewer queries; it is a faster and correct implementation with acceptable database and application costs.

Key Insight / Why This Solution Works
  1. Reproduce the page with a realistic number of parent records.
  2. Enable safe query logging or profiling and record normalized SQL, execution count, duration, and call location.
  3. Confirm that one parent query is followed by the same child query once per parent.
  4. Identify the required filters, ordering, pagination, authorization, relationship shape, and consistency guarantees.
  5. Choose a LEFT JOIN, verified ORM eager loading, or a two-query batched preload.
  6. Select only the columns and child rows required by the page.
  7. Bind every untrusted value and generate only trusted placeholder syntax.
  8. Verify an appropriate index on the child lookup columns and inspect the query plan.
  9. Group child rows by parent identifier and attach them while preserving parents with no children.
  10. Use bounded batches if the parent identifier set is too large for one efficient query.
  11. Test result correctness and compare query count, database time, transferred rows, peak memory, and total response time.
Code
<?php

declare(strict_types=1);

$pdo = new PDO(
    'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
    'app_user',
    'app_password',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

// Query 1: Load only the parent rows displayed on this page.
$parentStatement = $pdo->prepare(
    'SELECT id, title
     FROM posts
     WHERE status = :status
     ORDER BY created_at DESC, id DESC
     LIMIT 50'
);
$parentStatement->execute(['status' => 'published']);
$posts = $parentStatement->fetchAll();

if ($posts === []) {
    echo json_encode([], JSON_THROW_ON_ERROR);
    exit;
}

$postIds = array_map(
    static fn(array $post): int => (int) $post['id'],
    $posts
);

// PDO needs one placeholder for each value in the IN list.
$placeholders = [];

foreach ($postIds as $index => $postId) {
    $placeholders[] = ':post_id_' . $index;
}

// Query 2: Load all required child rows for the displayed parents.
$childSql = sprintf(
    'SELECT id, post_id, body, created_at
     FROM comments
     WHERE post_id IN (%s)
     ORDER BY post_id ASC, created_at ASC, id ASC',
    implode(', ', $placeholders)
);

$childStatement = $pdo->prepare($childSql);

foreach ($postIds as $index => $postId) {
    $childStatement->bindValue(
        ':post_id_' . $index,
        $postId,
        PDO::PARAM_INT
    );
}

$childStatement->execute();

$commentsByPostId = [];

while ($comment = $childStatement->fetch()) {
    $postId = (int) $comment['post_id'];
    $commentsByPostId[$postId][] = $comment;
}

foreach ($posts as &$post) {
    $postId = (int) $post['id'];
    $post['comments'] = $commentsByPostId[$postId] ?? [];
}
unset($post);

echo json_encode(
    $posts,
    JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
);
Why Interviewers Ask This

This question evaluates whether the candidate can recognize database work caused by application loops, diagnose it with query logging or profiling, choose an appropriate loading strategy, use PDO safely, preserve result correctness, and explain tradeoffs involving joins, batching, indexes, consistency, transferred data, and application memory.

Common interview mistakes

Common mistakes include changing the code without first confirming the repeated-query pattern; testing only with a tiny dataset; assuming an ORM method eagerly loads data without checking its generated SQL; executing a child query inside a PHP loop; replacing the code with an INNER JOIN and accidentally removing parents with no children; joining multiple one-to-many relationships and causing row multiplication; loading all columns or all children when only a subset is needed; interpolating identifiers or values from untrusted input; attempting to bind an entire PHP array to one PDO placeholder; forgetting a useful child foreign-key index; assuming an index will always be selected; creating an excessively large IN list; loading children before applying parent pagination; changing ordering, filtering, authorization, null handling, or consistency behavior; and measuring only query count while ignoring rows transferred, peak memory, database load, and total response time.

Interview tip

Start by defining the pattern as one parent query followed by one child query per parent. Explain how you would prove it with query logs, then present a join, verified eager loading, or two-query preloading as possible fixes. Finish with indexes, batching, consistency, correctness tests, and before-and-after measurements.

Interviewer may ask next
When would you use a join instead of two-query preloading?

I would use a join when the required result is naturally tabular, the joined relationship will not create excessive row duplication, and repeated parent columns are acceptable. I would normally use a LEFT JOIN when parents without children must remain. I would prefer two-query preloading when I need a nested parent-and-child structure, when several one-to-many joins would multiply rows, or when grouping separate relationship results is clearer and more efficient.

How would you handle thousands of parent identifiers without creating an extremely large IN clause?

I would first paginate or otherwise limit the parent set to the records actually needed. If a large set is still required, I would split the identifiers into measured, bounded batches, execute one safely parameterized child query per batch, and merge the grouped results. Depending on the database and query plan, I would also evaluate a join, derived table, temporary table, or supported bulk-input mechanism. I would not assume one batch size or strategy is optimal for every database.

60. What database indexes would you add for a slow PHP query, and how would you verify the improvement?Sql / DatabaseMedium

Question Details

Given a query with filters, joins, and ordering, explain how to inspect its execution plan, choose single or composite indexes, verify selectivity, and measure before and after performance.

Short Interview Answer (30-60 seconds)

I would inspect the actual execution plan first, then index selective filter and join columns, using a composite index when its leading-column order matches the query. I would verify the result with repeated before-and-after tests of rows processed, sort work, execution time, and write cost.

Detailed Explanation

This question asks how I would make a slow request for stored information faster without guessing. I must first see the exact request, the values supplied with it, how much information is stored, and how the information is arranged. Then I choose a smaller, faster lookup path that matches the way the request searches, combines, and orders records. Finally, I repeat the same tests before and after the change to prove that it reduces work consistently without causing unacceptable extra work when records are added, changed, or removed.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Which database engine and version are being used?
  • What is the exact SQL statement and its representative bound values?
  • Which filters are mandatory, and which are optional?
  • What are the table sizes and relevant value distributions?
  • Which indexes and constraints already exist?
  • How many rows does the query normally return?
  • Is the workload read-heavy, write-heavy, or mixed?
  • Is the main target lower latency, lower database load, or both?
What database indexes would you add for a slow PHP query, and how would you verify the improvement? diagram
How to Explain It in an Interview

I would begin with the exact SQL statement generated or executed by the PHP application and representative parameter values. PHP should bind untrusted values through PDO or another safe database API, but parameter binding does not make the query fast and does not decide which index the database uses. Index selection and execution planning are database responsibilities.

First, I would establish a baseline in a safe environment using production-like data. I would record the database execution time over multiple runs, rows returned, rows read or examined, join operations, temporary structures, sorting work, and relevant input/output activity when the engine exposes it. I would separate database execution time from total PHP request time because network delay, connection acquisition, PHP processing, rendering, and other application work can hide or exaggerate the database cost.

Next, I would inspect the execution plan with the database engine's supported planning command, such as EXPLAIN, EXPLAIN ANALYZE, or an equivalent runtime-plan facility. A query plan shows how the engine scans tables, uses indexes, joins rows, and performs ordering. Runtime-plan commands may execute the query, so I would use them carefully for statements that modify data or are expensive in production.

I would look for large full-table or full-index scans, unexpectedly high row counts, repeated nested lookups, inefficient join order, temporary tables, explicit sorting, and large differences between estimated and actual row counts. A large estimate error can indicate stale statistics, skewed data, correlated columns, or parameter-sensitive behavior. Updating statistics may improve the plan without adding an index, although I would test that rather than assume it.

I would then review the predicates, meaning the filter and join conditions. Columns used in selective equality filters and joins are common index candidates. Selectivity describes how much a condition narrows the result. A condition matching a small fraction of a table is usually more selective than one matching most rows. I would inspect actual data distribution and engine statistics because a column with many distinct values can still contain individual values that occur very frequently.

A single-column index is appropriate when one column is commonly searched independently and existing indexes do not already support that access pattern. A composite index is appropriate when the same query pattern repeatedly filters or joins on multiple columns together. I would not create one index for every column in the WHERE, JOIN, or ORDER BY clauses because separate indexes may not combine efficiently, may duplicate existing indexes, and always add maintenance cost.

For a composite index, column order is critical. As a practical starting point, I would place columns used by stable equality conditions before a range condition, then consider columns needed for ordering or covering the query. However, this is not a universal formula. The best order depends on the database engine, optional predicates, value distribution, join strategy, sort direction, and the full workload.

Many B-tree composite indexes follow a leftmost-prefix principle. For an index on columns A, B, and C, the engine can commonly use the ordered prefix beginning with A, such as A alone or A with B. It normally cannot use the same index as efficiently for a search beginning only with B or C. Some engines can use skip-scan or index-combination strategies in limited cases, so I would verify behavior in the actual plan rather than state that non-leading columns are never usable.

For joins, I would verify that the lookup-side join column is indexed where appropriate and that joined columns have compatible data types, lengths, collations, and semantics. Primary-key or unique indexes may already cover the join. I would also confirm that expressions, implicit conversions, or functions applied to indexed columns are not preventing an efficient lookup. When an expression is required, an engine-supported functional or generated-column index may be an option.

For ORDER BY, I would check whether an index can provide rows in the required order after applying the leading filter conditions. This can avoid a separate sort, but only when the index column order, sort directions, query predicates, and engine rules align. If the query returns a large portion of the table, the optimizer may correctly prefer a scan and sort over many random index lookups.

I would also consider whether a covering index is justified. A covering index contains all columns needed for a particular query, allowing some engines to answer it with fewer table lookups. This can improve read performance, but including extra columns increases index size, memory pressure, storage use, cache churn, and write maintenance. I would use it only when measurements show a worthwhile benefit for an important query.

After selecting a candidate index, I would create it in a safe environment using the database engine's appropriate online or low-lock method when available. Large index builds can consume CPU, input/output capacity, temporary storage, replication bandwidth, and locks, so the deployment method matters. I would verify that statistics are current and then rerun the same plan analysis with the same representative parameters.

I would confirm that the intended index is selected or that the new plan is otherwise better. The important evidence is reduced work, such as fewer rows processed, fewer table lookups, less temporary activity, or removal of an expensive sort. Merely seeing the new index name in a plan is not enough; an index scan that reads most of the index may offer little improvement.

I would then repeat the timing tests under comparable conditions. I would include cold and warm cache scenarios when relevant, use multiple parameter values, and report a stable measure such as median and high-percentile latency rather than one fastest run. Parameter values matter because a plan that is efficient for a rare value may be inefficient for a common value. I would also test concurrency when the production problem appears only under load.

Finally, I would evaluate the tradeoffs. Every additional index consumes disk space and database cache memory. INSERT operations must add index entries, DELETE operations must remove them, and UPDATE operations must maintain each index whose indexed values change. Extra indexes can lengthen backups, restores, replication, schema changes, and maintenance operations. I would keep the smallest non-duplicated index set that produces a meaningful, repeatable improvement across the important workload, deploy it with monitoring and a rollback plan, and verify production results after release.

Technical Approach
  1. Capture the exact SQL statement and representative bound values from the PHP application.
  2. Confirm the database engine, version, schema, existing indexes, constraints, table sizes, and workload pattern.
  3. Establish a baseline using production-like data and multiple comparable executions.
  4. Measure database time separately from total PHP request time.
  5. Inspect the estimated plan and, when safe, the actual runtime plan.
  6. Identify expensive scans, joins, sorts, temporary work, lookup repetition, and estimate errors.
  7. Check whether stale statistics, implicit conversions, functions, or query structure are the real cause.
  8. Evaluate filter and join columns using actual selectivity and data distribution.
  9. Choose the smallest suitable single-column or composite index while avoiding redundant indexes.
  10. Order composite-index columns according to equality, range, ordering, coverage, optional filters, and engine-specific rules.
  11. Build the candidate index safely and refresh or verify statistics when required.
  12. Rerun the same plans and confirm that total database work decreases.
  13. Repeat timings with multiple representative values, cache states, and concurrency levels when relevant.
  14. Measure the effect on INSERT, UPDATE, DELETE, storage, memory use, maintenance, and deployment operations.
  15. Deploy with monitoring and rollback capability, then verify production performance.
Practical Insights

Without a useful index, the database may need to inspect a large part of a table, so the work can grow roughly with the number of stored rows. With a suitable B-tree index, locating the start of a matching range is commonly logarithmic, followed by work proportional to the matching index entries and any required table lookups. These are simplified expectations, not guaranteed timings. Actual cost depends on the engine, data distribution, cache state, storage, joins, sorting, and concurrency. Each index also consumes disk and cache memory and adds maintenance work to inserts, deletes, and updates that affect indexed columns. Wider composite or covering indexes increase those costs further.

Why Interviewers Ask This

This question tests whether the candidate can diagnose database performance systematically instead of adding indexes by guesswork. The interviewer is evaluating execution-plan analysis, index selectivity, composite-index ordering, join and sort behavior, measurement discipline, and awareness of storage and write costs. It also checks whether the candidate correctly separates PHP application behavior from database optimization: PHP submits the parameterized statement and measures the request, while the database optimizer chooses the access path and performs the index lookup.

Common interview mistakes

Common mistakes include recommending exact index columns without seeing the SQL or schema, adding indexes before reading the execution plan, and indexing every referenced column. Other mistakes are ignoring existing or overlapping indexes, choosing composite columns from the textual order of the WHERE clause, treating high distinct-value counts as proof that every value is selective, and overlooking optional filters or parameter-sensitive plans. Candidates may also ignore implicit type conversions, functions on indexed columns, stale statistics, large result sets, or sorting requirements. Measurement errors include timing only one run, comparing different parameter values, reporting total PHP latency as database latency, testing only a warm cache, or assuming that use of an index proves improvement. Production mistakes include building a large index without considering locks and resource use, forcing a plan without strong evidence, and ignoring write, storage, replication, backup, and maintenance costs.

Interview tip

Present the answer as a measured sequence: capture the exact query, establish a baseline, inspect the actual plan, choose the smallest matching index, compare plans and repeated timings, and evaluate write and operational costs. Do not guess specific columns when the interviewer has not supplied the query or schema.

Interviewer may ask next
How would you decide the column order in a composite index?

I would use the real query patterns and the database engine's rules. Stable equality conditions are often useful as leading columns, followed by a range column and then columns that may support ordering or coverage. I would also account for optional filters, join direction, value distribution, sort direction, and other queries that need the index. The written order of predicates in the SQL does not determine index order, so I would verify the choice with actual execution plans and repeated measurements.

What would you do if the database does not use the new index?

I would first check whether avoiding the index is actually the cheaper plan. The query may return too many rows, the index may have the wrong leading columns, statistics may be stale, values may be highly skewed, or functions and implicit conversions may block an efficient lookup. I would also check covering needs, join order, sorting, parameter-sensitive behavior, and overlapping indexes. I would test representative values and compare actual work before considering an engine-specific hint, because forcing an index can make other parameter values or future data distributions slower.

More questions load as you scroll

Disclaimer: This interview guide is for educational and informational purposes only. It is designed to help readers prepare, but it does not guarantee any interview result, hiring decision, offer, or outcome. Interview questions, hiring criteria, and preferred answers can vary by employer, interviewer, industry, location, and time. The examples and explanations reflect the authors' research and judgment, are provided without warranties of any kind, and should not be treated as the only correct approach. Diagrams are simplified illustrations intended to highlight the main components and their interactions; actual systems and implementations may be more complex. Alternative approaches may be equally valid or better suited to a particular question, context, or interviewer. To the fullest extent permitted by applicable law, the author, contributors, and publisher are not liable for decisions made, actions taken, or losses incurred based on this guide.

Content Accuracy and Verification: To the fullest extent permitted by applicable law, we do not represent or warrant that interview guides, questions, answers, examples, or diagrams are accurate, complete, current, error-free, or suitable for any particular purpose. You are responsible for independently reviewing and verifying the information before relying on it.