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: August 28, 2026)

61. What is PDO in PHP?NEWSql / DatabaseEasy

Question Details

Define PHP Data Objects as a consistent database-access interface available through database-specific drivers. Explain PDO objects, DSNs, connections, prepared statements, bound values, result fetching, exceptions, transactions, persistent connections, and driver differences. Clarify that PDO is not an ORM and does not make SQL portable automatically.

Short Interview Answer (30-60 seconds)

PDO stands for PHP Data Objects. It gives PHP a consistent interface for accessing different databases through drivers. It supports prepared statements, parameter binding, result fetching, transactions, and exceptions. PDO is not an ORM, and database-specific SQL can still differ.

Detailed Explanation

See the Code while reading this explanation.

PDO gives a PHP program one common way to communicate with several kinds of databases. The program opens a connection, sends instructions, supplies values, receives matching records, and can group several changes so they succeed or fail together. This makes application code more consistent when developers work with different database products. However, each database still has its own rules and features, so changing the database may require changes to the instructions the program sends. PDO helps PHP communicate with databases; it does not turn database records into application objects automatically.

Useful Questions to Ask the Interviewer
  1. Would you like me to focus on basic PDO usage or also explain prepared statements, transactions, and driver differences?
  2. Should I show a short PHP example using a specific database such as MySQL?
What is PDO in PHP? diagram
How to Explain It in an Interview

PDO means PHP Data Objects. It is a PHP extension that provides a consistent database-access API while relying on a database-specific PDO driver underneath.

A PDO object normally represents a connection to a database. PHP creates it with a DSN, which means Data Source Name. The DSN identifies the PDO driver and contains connection information required by that driver. For example, a MySQL DSN can contain the host, port, database name, and character set.

For values that come from users or other untrusted sources, I normally use prepared statements instead of concatenating those values into SQL. I call prepare() with placeholders and then provide the values separately using execute(), bindValue(), or bindParam(). PDO treats those placeholders as data values rather than SQL identifiers. Exact preparation and binding behavior can depend on the PDO driver and whether native or emulated prepared statements are being used. Prepared statements are still the normal safe way to pass untrusted data values when used correctly.

Prepared-statement parameters cannot be used for table names, column names, sort directions, SQL keywords, or other identifiers. If an identifier must be dynamic, the application should choose it from a trusted allowlist instead of inserting arbitrary user input.

A PDOStatement object represents a prepared or executed statement. Results can be read with methods such as fetch() for one row or fetchAll() for multiple rows. A fetch mode such as PDO::FETCH_ASSOC can return each row as an associative array.

For error handling, modern PDO code should normally use PDO::ERRMODE_EXCEPTION. Database errors then raise PDOException, which allows the application to handle failures explicitly. Production code should avoid exposing raw database exception details to end users because they may reveal sensitive implementation information.

PDO supports transactions with beginTransaction(), commit(), and rollBack(). A transaction groups related database changes into one unit of work. If all required operations succeed, the application commits. If an operation fails, it can roll back the active transaction when the database, driver, and storage engine support transactional behavior. Isolation levels, locking behavior, supported SQL, and many transaction details are controlled by the database system rather than by PDO itself.

PDO can request persistent connections by setting PDO::ATTR_PERSISTENT when the driver supports them. A persistent connection can remain available for reuse by later PDO objects in the same relevant PHP process instead of requiring a new physical database connection every time. Persistence behavior depends on the PHP SAPI and PDO driver. Persistent connections should be used carefully because connection state can survive longer than expected and database connection capacity can become an operational concern.

The important portability limit is that PDO standardizes much of the PHP-facing API, not the SQL language itself. MySQL, PostgreSQL, SQLite, and other databases can differ in SQL syntax, data types, built-in functions, generated-key behavior, transaction features, and driver-specific PDO options. Moving an application to another database may therefore require SQL, schema, configuration, and application changes.

PDO is also not an ORM. An ORM usually maps application objects or models to database data and adds higher-level features such as relationships and query abstractions. PDO stays much closer to SQL: the developer writes the SQL and decides how returned rows are used.

Technical Approach
  1. Choose the PDO driver required by the target database.
  2. Build the correct DSN and create the PDO connection.
  3. Configure appropriate error handling, normally PDO::ERRMODE_EXCEPTION.
  4. Prepare SQL with placeholders for data values.
  5. Pass values separately through execute(), bindValue(), or bindParam().
  6. Fetch results using the required fetch mode.
  7. Use a transaction when related writes must succeed or fail as one unit.
  8. Roll back an active transaction when an operation fails.
  9. Release PDO and PDOStatement references when they are no longer needed.
  10. Use persistent connections only when their driver, SAPI, connection-state, and capacity implications are understood.
Practical Insights

PDO does not determine the main time or memory cost of a database query. The database still does the expensive work, and performance depends on the SQL, indexes, amount of data, query plan, locks, network communication, and returned result size. Fetching many rows also uses more application memory, especially with fetchAll(), because all selected rows can be stored in PHP memory at once. Prepared statements can be useful for repeated execution, but they do not automatically make a poorly designed query fast. Persistent connections may reduce repeated connection setup work, but they can keep database connections available for longer and must be managed with database capacity in mind. Maintenance can be easier because PDO provides a consistent PHP API, although database-specific SQL and behavior still have to be maintained.

Code
<?php

declare(strict_types=1);

$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=app;charset=utf8mb4';
$username = 'app_user';
$password = 'app_password';

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

    $statement = $pdo->prepare(
        'SELECT id, email FROM users WHERE email = :email'
    );

    $statement->execute([
        'email' => 'user@example.com',
    ]);

    $user = $statement->fetch();

    if ($user === false) {
        echo "User not found\n";
    } else {
        echo 'ID: ' . $user['id'] . PHP_EOL;
        echo 'Email: ' . $user['email'] . PHP_EOL;
    }
} catch (PDOException $exception) {
    fwrite(STDERR, "Database operation failed.\n");
    exit(1);
}
Why Interviewers Ask This

Interviewers want to confirm that the candidate understands PHP's standard database-access interface and can use it safely. A strong answer should distinguish PDO from its database-specific drivers, explain prepared statements and parameter binding, understand connection and transaction handling, and recognize that PDO does not replace SQL or provide ORM-style object mapping.

Common interview mistakes

Common mistakes include saying PDO is a database or an ORM, or claiming that using PDO automatically makes SQL portable across databases. Another mistake is concatenating untrusted values into SQL instead of using prepared-statement parameters. Candidates should not claim that placeholders can safely represent table names, column names, sort directions, or other SQL identifiers. Other mistakes include ignoring database exceptions, forgetting to roll back an active failed transaction, assuming every PDO driver behaves identically, calling fetchAll() on very large result sets without considering PHP memory use, and enabling persistent connections without considering connection state and database capacity.

Interview tip

Start with one clear sentence: PDO is PHP's consistent database-access interface. Then explain the connection, DSN, prepared statements, bound values, fetching, exceptions, and transactions. Finish with the two important limits: PDO is not an ORM, and it does not make database-specific SQL automatically portable.

Interviewer may ask next
Why should you use prepared statements with PDO?

Prepared statements let the application keep SQL structure separate from data values. The SQL contains placeholders, and the application provides the values separately with execute() or binding methods. When used correctly, this prevents untrusted values from being interpreted as SQL syntax. Exact handling can depend on the PDO driver and whether prepared statements are native or emulated. Placeholders represent data values only; they cannot safely replace table names, column names, SQL keywords, or sort directions.

Can I switch from MySQL to PostgreSQL without changing my application if I use PDO?

Not necessarily. PDO gives MySQL, PostgreSQL, and other supported databases a similar PHP-facing interface for operations such as connecting, preparing statements, executing queries, and fetching results. However, SQL syntax, data types, built-in functions, generated-key behavior, transaction features, schema definitions, and driver-specific options can differ. Changing the database can therefore require SQL, schema, configuration, and application changes even when both databases are accessed through PDO.

62. What is an ORM in PHP?NEWSql / 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.

63. 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.

64. 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))

65. How should a PHP application handle database schema migrations?Sql / DatabaseMedium

Question Details

Explain version-controlled migration files, forward and rollback behavior, deployment ordering, backward-compatible changes, data migrations, locking, and recovery from partial failure.

Short Interview Answer (30-60 seconds)

I use ordered, version-controlled migrations run once by the deployment pipeline, not by web requests. I record completed versions, lock the runner, use expand-and-contract changes for compatibility, batch large data updates, and recover with safe retries, corrective migrations, or a tested restore plan.

Detailed Explanation

This question asks how a PHP team should safely change the way stored information is organized while the application continues to serve users. Each change must be saved, reviewed, applied in the correct order, and recorded so it is not repeated. The answer should also explain how an older and a newer application release can both keep working during an update, how existing information is moved safely, how two update jobs are prevented from running together, and how the team recovers when only part of a change finishes.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Which database system and migration tool are being used?
  • Must the deployment avoid application downtime?
  • Can old and new PHP versions run at the same time during deployment?
  • How large are the affected tables and data sets?
  • Which schema operations can the database execute transactionally?
  • What backup, restore, and deployment rollback procedures are available?
How should a PHP application handle database schema migrations? diagram
How to Explain It in an Interview

I treat migrations as production code. Each database change belongs in an ordered, immutable, version-controlled migration file. It should be reviewed with the PHP code that depends on it, tested against a production-like copy of the previous schema, and executed through a dedicated deployment or command-line process.

I would not run migrations automatically from a normal PHP web request or from every application instance during startup. That can create duplicate execution, long request times, lock contention, and unpredictable failures. One controlled deployment job should run the migrations before or during the application rollout according to the compatibility plan.

The migration tool should maintain a history table containing a unique migration identifier and the fact that it completed. Before applying a migration, the runner reads this history and validates the expected current version. It records completion only after the required work has succeeded. Applied migration files should not later be edited because different environments may already have executed the original contents. A correction should be a new migration.

The normal strategy is to move forward. A rollback function can be useful for a simple, reversible change, such as removing a newly added unused object. However, rollback is unsafe when a migration drops columns, deletes rows, changes values irreversibly, or has already been used by a newer PHP release. For these cases, I prefer a corrective forward migration or restoration from a verified backup rather than claiming that every change can be reversed.

Deployment ordering matters because the schema and PHP code may not change at the same instant. For zero-downtime or rolling deployments, I use an expand-and-contract sequence:

  1. Add the new table, column, index, or other structure without removing the old one.
  2. Deploy PHP code that remains compatible with the old and new database states.
  3. When necessary, temporarily write to both representations or read from the new representation with a controlled fallback.
  4. Backfill existing rows in restartable batches.
  5. Verify completeness and switch all reads and writes to the new structure.
  6. Confirm that no deployed PHP version still depends on the old structure.
  7. Remove the old structure in a later release.

For example, directly renaming a column can break older PHP workers that still query its original name. A safer process is to add the replacement column, deploy compatible PHP code, copy the existing values, switch application access, verify the result, and remove the old column only after every running version has stopped using it.

Schema migrations and data migrations should usually be separated when the data operation is large. A schema migration changes database objects such as tables, columns, constraints, or indexes. A data migration changes existing rows. Combining a long backfill with a deployment-critical schema change can extend locks, increase database load, enlarge transaction or replication logs, delay replicas, and make recovery harder.

A large data migration should process a limited number of rows per batch using a stable key, such as a primary key, to mark progress. Each batch should be safe to retry. The job should commit between batches when appropriate, pause or reduce its rate if database load becomes unsafe, and verify the result with checks such as remaining-row counts, invalid-value counts, or application-specific invariants. It should not load the entire table into PHP memory.

Transaction behavior must be checked for the selected database and operation. Some databases support transactional schema changes for many statements. Other databases implicitly commit certain schema statements or cannot roll them back. Therefore, calling beginTransaction() in a PHP migration runner does not by itself guarantee that an entire schema migration is atomic. The migration design and recovery plan must match the actual database behavior.

When a migration is fully transactional, the schema or data changes and the migration-history record should be committed together where the tool and database permit it. When operations are not transactional, the migration should use small, explicit, observable steps. Each step should either be idempotent or check the current database state before continuing. Idempotent means safely repeating the step reaches the same correct result instead of creating duplicates or corruption.

Only one migration runner should operate on the same database at a time. I would use the migration tool's database-backed locking feature, a database advisory lock, or a dedicated lock row with safe atomic acquisition. A lock stored only in one PHP process or on one application server is insufficient when several servers or deployment jobs exist. The lock should have a bounded wait policy, clear failure reporting, and release behavior when the migration connection ends or the runner exits.

The migration runner should use a dedicated database connection with migration-specific credentials. Those credentials should have only the permissions required for the approved migration process. The runner should fail on database errors, log the migration identifier and failed step without exposing secrets, release its lock, close the connection, and return a nonzero command-line status so the deployment pipeline stops.

Before deployment, I would test both a clean installation and an upgrade from the exact previous production version. I would also estimate table size, expected lock behavior, index build cost, replication impact, available disk space, backup readiness, and whether the PHP release can operate in every intermediate state.

If a migration fails after some operations have succeeded, I would stop the remaining deployment and inspect the actual database state. I would not blindly rerun the complete file, blindly execute a rollback, or manually mark the history record as complete. Recovery depends on what committed:

  • Retry the failed step when it is safe and idempotent.
  • Apply a new corrective forward migration when the partial state needs repair.
  • Restore from a tested backup or snapshot when destructive damage cannot be repaired safely.

After recovery, I would verify the schema, constraints, indexes, affected data, migration history, application health, and replica health before resuming deployment. The central principle is that every migration must have a defined forward path, compatibility window, concurrency policy, verification method, and recovery plan.

Technical Approach
  1. Define the target schema and identify every PHP version that may run during deployment.
  2. Create a new ordered migration file and never modify an already-applied migration.
  3. Separate quick schema changes from long-running data backfills.
  4. Design destructive or incompatible changes with expand-and-contract phases.
  5. Test a clean installation and an upgrade from the exact previous production schema.
  6. Check database-specific transaction, locking, index-build, and rollback behavior.
  7. Prepare backups, verification queries, observability, and a recovery decision before deployment.
  8. Run the migration once through a dedicated command-line or deployment job.
  9. Acquire a database-backed lock and validate the current migration history.
  10. Apply each change in order and record completion only after its required work succeeds.
  11. Run large data changes in bounded, restartable, rate-controlled batches.
  12. Verify schema objects, constraints, indexes, affected data, application compatibility, and replica health.
  13. Release the lock, close the migration connection, and allow the deployment to continue only after success.
  14. Remove obsolete structures in a later deployment after no running PHP version uses them.
  15. On partial failure, stop, inspect committed state, and choose a safe retry, corrective migration, or tested restore.
Practical Insights

The cost depends on the database, table size, operation, and available online-change features. A small metadata change may finish quickly, while adding an index, validating a constraint, changing a column type, or rebuilding a table may read or rewrite many rows and use substantial temporary disk space. A data backfill normally takes work proportional to the number of rows it examines or changes. Small batches keep PHP memory use bounded because only one batch is held at a time, but they add repeated query and commit overhead. Temporary old-and-new structures require extra storage, code, monitoring, and cleanup. Long transactions can retain locks and old row versions, grow logs, delay replicas, and make rollback expensive, so production migrations should be measured and rate-controlled.

Why Interviewers Ask This

Interviewers want to know whether the candidate can change a production database without breaking running PHP application versions or losing data. The question evaluates migration versioning, deployment ordering, backward compatibility, schema and data migration design, concurrency control, transaction limitations, operational risk, and recovery judgment. It also tests whether the candidate understands that a database deployment and an application deployment may complete at different times.

Common interview mistakes

Common mistakes include running untracked SQL manually in production; executing migrations from normal web requests; allowing every PHP instance to migrate during startup; editing a migration after it has been applied; assuming a PHP transaction makes every schema statement reversible; recording migration completion before all required steps succeed; deploying PHP code before its required schema exists; dropping or renaming objects while older PHP versions still use them; combining a long backfill with a deployment-critical schema change; processing an entire table in PHP memory; using offset pagination for a changing backfill instead of stable key-based progress; holding one very large transaction unnecessarily; ignoring table locks, disk usage, replicas, and transaction-log growth; relying on a process-local lock in a multi-server deployment; blindly retrying a partially committed migration; treating every rollback method as safe; manually changing the migration-history table without repairing the database; and removing the old structure before compatibility and data verification are complete.

Interview tip

Present the answer in this order: version-controlled migrations, one controlled runner, migration history, expand-and-contract deployment, separate batched data migrations, database-specific transaction limits, database-backed locking, and partial-failure recovery. State clearly that rollback is not always safe and every intermediate schema must support the PHP versions that can still be running.

Interviewer may ask next
How would you safely rename a database column without downtime?

I would use expand-and-contract rather than a one-step rename. First, I would add the new column. Next, I would deploy PHP code that can work with both columns and, when necessary, temporarily write to both. I would backfill old rows in restartable batches, verify that the new column is complete, switch all reads and writes to it, confirm that no running PHP version uses the old column, and remove the old column in a later migration.

What should happen if a migration fails after some statements have already committed?

The deployment should stop and preserve the exact error, migration identifier, and completed-step information. I would inspect the real schema and data state instead of blindly rerunning or rolling back the file. If the next operation is idempotent, I would correct the cause and retry safely. Otherwise, I would apply a new corrective forward migration or restore from a verified backup when destructive damage cannot be repaired. I would update migration history only after the database reaches a confirmed consistent state.

66. 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.

67. How do you prevent lost updates when two PHP requests modify the same row?Sql / DatabaseMedium

Question Details

Compare optimistic locking with a version column, pessimistic row locks, transaction isolation, retries, and how to return a conflict to the client.

Short Interview Answer (30-60 seconds)

I prefer an atomic UPDATE when possible. Otherwise, I use optimistic locking with a version column and return HTTP 409 when the version changed. For short, high-contention read-modify-write work, I use SELECT FOR UPDATE in a transaction and retry only recognized transient failures.

Detailed Explanation

See the Code while reading this explanation.

This question asks how to stop one person's saved change from silently replacing another person's change when both work on the same record at nearly the same time. The answer should explain whether the program detects the clash when saving or temporarily makes the other request wait. It should also cover what happens after a clash, whether trying again is safe, and what response the user or calling system receives.

Useful Questions to Ask the Interviewer
  1. Are clashes rare or frequent?
  2. Is the new value based on an earlier read?
  3. Can the change be safely repeated?
  4. Must users review competing edits?
How do you prevent lost updates when two PHP requests modify the same row? diagram
How to Explain It in an Interview

A lost update occurs when two PHP requests read the same row, both calculate a new value from that old state, and then both write. The second write can silently replace the first.

My first choice is an atomic SQL statement when the change can be expressed entirely in the database. For example, this avoids a PHP-side read followed by a replacement write:

UPDATE counters SET value = value + :amount WHERE id = :id;

The database applies the increment as one statement, so concurrent increments are not lost. This is usually simpler and safer than reading the value into PHP, adding to it, and writing the result back.

For editable resources such as profiles, tickets, or product records, I normally use optimistic locking when conflicts are expected to be uncommon. The table contains a version column such as an integer declared NOT NULL with an initial value. The client receives that version when it reads the row. The update succeeds only if the stored version still equals the version the client originally read:

UPDATE accounts SET balance = :balance, version = version + 1 WHERE id = :id AND version = :expected_version;

The version comparison and increment happen in the same statement. If the statement affects one row, the update succeeded. If it affects zero rows, the row may not exist or its version may have changed. Because the version is incremented on every successful match, PDO::rowCount() is suitable here for determining whether the guarded update matched under the expected MySQL behavior. If the application must distinguish a missing row from a version conflict, it can perform a separate read after the failed update, while accepting that the row may change again between those statements.

For a version conflict, the API should normally return HTTP 409 Conflict. It can include the current version and current representation so the client can reload, merge changes, or ask the user to choose. The server must not silently issue an unconditional UPDATE because that would recreate the lost-update problem.

Optimistic locking does not keep a database lock while a user is viewing or editing data. That makes it a good default for web applications. Its cost is that conflicts must be handled explicitly. A retry is safe only when the operation can be recomputed from the newest state without hiding another user's meaningful change.

For short operations that must read the current row, make a decision, and write while excluding competing writers, I use pessimistic locking. The PHP request starts a transaction, reads the row with SELECT ... FOR UPDATE, performs the validation and update, and commits. Competing transactions requesting an incompatible lock on that row wait until the first transaction commits or rolls back.

The locked transaction must remain short. It should not wait for user input, make a slow network call, send an email, or perform unrelated work while holding the lock. Long transactions increase lock waiting, reduce throughput, and increase the chance of deadlocks. The lookup should use a primary key or another suitable index so the database can locate the intended row efficiently and avoid locking more data than necessary.

Transaction isolation must be discussed carefully. Starting a transaction does not automatically make a plain SELECT followed by an unconditional UPDATE safe. Under commonly used isolation levels, both transactions may still read the same old value and later overwrite each other. A conditional update, an atomic SQL expression, or an explicit locking read is still needed. Serializable isolation can prevent more concurrency anomalies, but it can reduce concurrency and may abort transactions with serialization failures, so retry handling is still required.

Isolation behavior and locking details vary by database engine. For example, SELECT FOR UPDATE syntax, lock scope, timeout behavior, and serialization error codes are database-specific. The application should test against the actual production database rather than assume identical behavior across MySQL, PostgreSQL, SQL Server, or another system.

Retries should be bounded and limited to recognized transient failures such as deadlocks, serialization failures, or lock timeouts when retrying is appropriate. Each attempt must begin a new transaction because a failed or rolled-back transaction cannot simply continue. A small retry limit with randomized backoff helps prevent all competing requests from retrying at the same moment.

The operation must also be safe to repeat. If a transaction charges a card, sends a message, publishes an event, or calls another service, blindly retrying may duplicate the side effect. The design should use an idempotency key, an outbox pattern, or another deduplication mechanism when external effects are involved.

In PDO, all statements in one transaction must use the same PDO connection. I enable exception-based error handling, bind untrusted values through prepared statements, commit only after every required statement succeeds, and roll back when an exception occurs and a transaction remains active. Prepared statements protect values, but they do not provide concurrency control and do not make untrusted table or column names safe.

Key Insight / Why This Solution Works
  1. Determine whether the change can be expressed as one atomic SQL statement.
  2. Use that atomic statement when possible instead of reading and replacing the value in PHP.
  3. For low-contention editing, return a version value with the row and require it in a conditional UPDATE.
  4. Increment the version in the same successful UPDATE.
  5. Check the affected-row count.
  6. If no row matched, distinguish not-found from conflict when required and return HTTP 409 for a version conflict.
  7. For short, contention-sensitive read-modify-write operations, begin a transaction and read the indexed row with SELECT FOR UPDATE.
  8. Validate and update on the same connection, then commit promptly.
  9. Roll back on failure.
  10. Retry only recognized transient database failures with a small limit and backoff.
  11. Ensure retries cannot duplicate external side effects.
Code
<?php

declare(strict_types=1);

final class ConflictException extends RuntimeException
{
    /** @param array{id: int, balance: string, version: int} $current */
    public function __construct(public readonly array $current)
    {
        parent::__construct('The row was changed by another request.');
    }
}

final class NotFoundException extends RuntimeException
{
}

/**
 * @return array{id: int, balance: string, version: int}
 */
function updateAccountBalance(
    PDO $pdo,
    int $accountId,
    string $newBalance,
    int $expectedVersion
): array {
    $statement = $pdo->prepare(
        'UPDATE accounts
         SET balance = :balance,
             version = version + 1
         WHERE id = :id
           AND version = :expected_version'
    );

    $statement->bindValue(':balance', $newBalance, PDO::PARAM_STR);
    $statement->bindValue(':id', $accountId, PDO::PARAM_INT);
    $statement->bindValue(':expected_version', $expectedVersion, PDO::PARAM_INT);
    $statement->execute();

    if ($statement->rowCount() === 1) {
        return [
            'id' => $accountId,
            'balance' => $newBalance,
            'version' => $expectedVersion + 1,
        ];
    }

    $currentStatement = $pdo->prepare(
        'SELECT id, balance, version
         FROM accounts
         WHERE id = :id'
    );
    $currentStatement->bindValue(':id', $accountId, PDO::PARAM_INT);
    $currentStatement->execute();

    $current = $currentStatement->fetch(PDO::FETCH_ASSOC);

    if ($current === false) {
        throw new NotFoundException('Account not found.');
    }

    throw new ConflictException([
        'id' => (int) $current['id'],
        'balance' => (string) $current['balance'],
        'version' => (int) $current['version'],
    ]);
}

$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,
    ]
);

header('Content-Type: application/json');

try {
    $result = updateAccountBalance(
        $pdo,
        accountId: 42,
        newBalance: '125.00',
        expectedVersion: 7
    );

    http_response_code(200);
    echo json_encode($result, JSON_THROW_ON_ERROR);
} catch (ConflictException $exception) {
    http_response_code(409);
    echo json_encode([
        'error' => 'conflict',
        'message' => $exception->getMessage(),
        'current' => $exception->current,
    ], JSON_THROW_ON_ERROR);
} catch (NotFoundException $exception) {
    http_response_code(404);
    echo json_encode([
        'error' => 'not_found',
        'message' => $exception->getMessage(),
    ], JSON_THROW_ON_ERROR);
} catch (Throwable $exception) {
    http_response_code(500);
    echo json_encode([
        'error' => 'internal_error',
        'message' => 'The update could not be completed.',
    ], JSON_THROW_ON_ERROR);
}
Why Interviewers Ask This

Interviewers use this question to test whether the candidate understands concurrent requests, lost updates, atomic SQL statements, optimistic and pessimistic locking, transaction isolation, database error handling, and client-visible conflict behavior. A strong candidate should choose an approach based on contention and business rules instead of assuming that a transaction or prepared statement alone prevents overwrites.

Common interview mistakes

Common mistakes include reading a value and later issuing an unconditional replacement UPDATE, assuming BEGIN automatically prevents lost updates, and assuming prepared statements provide concurrency protection. Other errors are ignoring the affected-row count, automatically overwriting after a version conflict, holding SELECT FOR UPDATE locks during network calls or user interaction, using different connections within one transaction, retrying a failed transaction without starting a new one, retrying every database exception, and duplicating payments, messages, or events during retries.

Interview tip

Begin with the decision order: atomic SQL first, optimistic locking for uncommon edit conflicts, and pessimistic locking for short contention-sensitive workflows. Then explain affected-row checks, HTTP 409, isolation limitations, short transactions, and bounded idempotent retries.

Interviewer may ask next
When would you choose SELECT FOR UPDATE instead of a version column?

I would choose SELECT FOR UPDATE when the operation must use the latest stored values to validate a rule and complete a write while preventing a competing transaction from changing the same row. It is suitable for short, contention-sensitive workflows. I would access the row through an appropriate index, keep the transaction brief, use one connection, and handle deadlocks or lock timeouts with bounded retries.

Should the server automatically retry every optimistic-lock conflict?

No. It should retry only when it can safely reload the newest state and recompute the operation without hiding another user's meaningful edit. An increment may be safely recomputed, but replacing an edited document usually requires the client or user to review the conflict. In that case, return HTTP 409 with the current version. Any retry involving external effects must also be idempotent.

68. How do you store and query JSON data from PHP without turning the database into an unstructured store?Sql / DatabaseMedium

Question Details

Explain when JSON columns are appropriate, validation, generated or expression indexes, querying nested values, migration concerns, and when normalized tables are better.

Short Interview Answer (30-60 seconds)

I use JSON only for controlled, record-owned attributes that may vary. I keep important and relational values in typed columns, validate the document, index frequently queried paths, use PDO parameters for values, version shape changes, and normalize data when it needs relationships or independent queries.

Detailed Explanation

This question asks how to save information whose shape can vary without letting important business data become disorganized. The main choice is which values may stay together and which need their own clearly defined places. Values used often for searching, sorting, reporting, rules, or links to other records should remain easy to find and check. Flexible details may stay grouped when they belong to one item. A good answer also explains how PHP checks incoming values, how searches stay fast, and how older saved records are handled when the expected shape changes.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Which database engine and version are being used?
  • Which nested values must be filtered, sorted, joined, grouped, or reported on?
  • Is the document shape controlled by the application or supplied by users or external services?
  • Which values are required, unique, or related to records in other tables?
  • Must old and new document shapes work at the same time during deployment?
How do you store and query JSON data from PHP without turning the database into an unstructured store? diagram
How to Explain It in an Interview

Start with the design boundary. JSON is suitable for optional or variable attributes that belong to one parent row and are normally read or updated as one unit. Examples include user preferences, integration metadata, controlled form answers, or product attributes that differ by product type. JSON should not become a container for every field in the record.

Keep stable business data in typed relational columns. This normally includes primary and foreign keys, ownership, status, timestamps, monetary values, quantities, and fields with strong validation rules. Use a separate normalized table when an item can occur many times, is shared by multiple records, has its own lifecycle, requires a foreign key, or must be queried and updated independently. Normalization means storing each important entity in an appropriate table and connecting it with keys.

The exact JSON type and operators depend on the database engine. PostgreSQL distinguishes json and jsonb, MySQL has a native JSON type, and other engines may provide different JSON support. The application should therefore choose one supported engine and write queries, indexes, and constraints for that engine rather than pretending JSON SQL is fully portable.

In PHP 8.4 or PHP 8.5, validate the application-level document before saving it. Check that the top-level value has the expected form, required keys are present, values have the correct PHP types, allowed values and ranges are respected, and unexpected keys are rejected or handled deliberately. Use json_encode() and json_decode() with JSON_THROW_ON_ERROR so malformed or unencodable data causes an exception instead of being silently treated as a valid result.

PHP validation provides clear application errors, but important rules should also be protected by the database when possible. A native JSON column normally rejects invalid JSON syntax. Engine-specific check constraints or JSON-schema functions may enforce selected document rules. However, values that require NOT NULL, UNIQUE, FOREIGN KEY, precise numeric, or strong date constraints are usually clearer and safer as normal typed columns.

Query nested values with the JSON extraction functions or operators provided by the selected database. Always distinguish a JSON value from its text or scalar form. For example, a quoted JSON string may not compare the same way as extracted text, and a number extracted as text can sort lexicographically rather than numerically. Cast or extract to the intended database type before comparison, sorting, or indexing.

Do not scan a large JSON column for every request. Identify the exact paths used by real filters, joins, sorting, grouping, or uniqueness checks. Then create an engine-supported generated-column index or expression index for those paths. A generated column derives a typed relational value from a JSON path and can be indexed where the engine supports it. An expression index stores the result of a particular extraction expression. Some engines also provide JSON-specific indexes that can support containment or key-existence queries.

The indexed type and expression must match the query. A numeric identifier should be indexed as a numeric type, not as text. A date should be converted consistently to an appropriate date or timestamp type. If the query uses a different operator, cast, collation, or path expression from the index definition, the optimizer may not use that index. Confirm the behavior with the database's EXPLAIN or equivalent query-plan command instead of assuming the index is active.

Indexes are not free. They consume storage and memory when active index pages are cached, and they add work to inserts, updates, deletes, backups, replication, and migrations. Index only important access paths and measure them with realistic data. JSON extraction may also use CPU and temporary working memory during scans, sorting, grouping, or expression evaluation. The exact memory cost depends on the engine, query plan, document size, result size, and configured work-memory limits, so it should not be described as a fixed amount.

From PHP, encode the validated value as JSON and pass it as a bound PDO value. Prepared-statement placeholders represent complete data values only. They cannot safely replace a table name, column name, SQL keyword, sort direction, operator, or arbitrary JSON path expression. Structural SQL choices must come from fixed application code or a strict allowlist. Never place untrusted input directly into SQL.

Use PDO in exception mode and handle database errors at the application boundary. Create connections according to the application's request or worker lifecycle rather than opening a new connection for every small operation. A normal web request may use one connection for its database work, while a long-running worker must detect broken or expired connections and avoid leaving transactions open across unrelated jobs.

For a single JSON insert or update, a transaction may not be necessary beyond the statement's own atomic behavior. Use an explicit transaction when a JSON change must remain consistent with updates to other rows or tables. Keep the transaction as short as practical. Know the selected database's isolation behavior rather than assuming every transaction prevents concurrent overwrites.

Avoid an unsafe read-modify-write sequence when two requests may edit the same document. Reading the whole document into PHP, changing one key, and writing it back can overwrite another request's change. Prefer an atomic database JSON-update function for one path when supported. Otherwise use row locking inside a short transaction or optimistic concurrency, such as checking a version number in the UPDATE condition and retrying when another writer has already changed the row.

Treat the JSON shape as a versioned contract. Add a schema-version field when more than one shape may exist. Deploy readers that understand both old and new shapes before changing writers. Migrate existing rows in controlled batches and monitor lock time, transaction-log growth, replication delay, failures, and application behavior. Remove old-shape support only after the migration is complete and rollback is no longer required.

Consider whether the database can update only part of the stored representation or must rewrite more data internally. This is engine-specific and can change the write, log, storage, and replication cost of large documents. Do not make universal claims that every partial JSON update rewrites either the whole document or only the changed bytes. Measure the behavior on the chosen engine and version.

Move a JSON value into a normal column when it becomes required, frequently filtered or sorted, used in joins, constrained as unique, referenced by other rows, or important to reporting. Move a nested collection into a related table when its elements have their own identity, lifecycle, relationships, or independent updates. This keeps JSON as controlled flexibility rather than an unstructured replacement for the relational model.

Technical Approach
  1. Identify the selected database engine and its JSON, constraint, and indexing capabilities.
  2. Classify each value as stable relational data, flexible parent-owned data, or an independent related entity.
  3. Store stable, required, relational, monetary, date, and constrained values in typed columns.
  4. Store only suitable flexible attributes in a native JSON column.
  5. Define and document the accepted document shape and schema version.
  6. Validate keys, value types, ranges, allowed values, and unknown fields in PHP.
  7. Add database constraints for critical rules where the selected engine supports them.
  8. List the nested paths used by actual filters, joins, sorting, grouping, reporting, or uniqueness checks.
  9. Add only the generated-column, expression, or JSON-specific indexes required by those access patterns.
  10. Match extraction types and query expressions to the index definitions.
  11. Verify index use and estimated row access with EXPLAIN or the engine's equivalent command.
  12. Encode and decode JSON with exceptions enabled and bind complete data values through PDO.
  13. Select identifiers, sort directions, operators, and path expressions from fixed application allowlists rather than user input.
  14. Use atomic path updates, row locks, or optimistic concurrency to prevent lost updates.
  15. Use short explicit transactions when JSON changes must stay consistent with other database changes.
  16. Deploy backward-compatible readers before writers and migrate old document shapes in controlled batches.
  17. Normalize values when they gain strong constraints, frequent independent queries, relationships, or their own lifecycle.
Practical Insights

Looking up a row through its primary key is normally efficient, but extracting values from its JSON still requires processing that document. Filtering an unindexed nested value may make the database inspect many or all candidate rows, so the work can grow roughly with the number of rows examined and the amount of JSON processed. A suitable index can avoid most of that scanning, but the exact speed depends on selectivity, statistics, caching, and the query plan. Every index uses additional disk space, may occupy database cache memory, and increases write and maintenance work. Large documents also increase transfer, parsing, logging, backup, replication, and migration costs. Query memory is not fixed: sorting, grouping, scans, extracted values, result size, engine settings, and the chosen plan determine how much working memory is needed. PHP also uses memory while holding the encoded string and decoded array or object, so applications should avoid loading unnecessarily large documents or result sets at once.

Why Interviewers Ask This

Interviewers want to evaluate whether the candidate can use JSON flexibility without abandoning relational design. The question tests schema judgment, validation, constraints, nested-value querying, generated or expression indexes, query-plan analysis, safe PDO parameter binding, concurrency control, migrations, performance tradeoffs, and the ability to recognize when a normal column or related table is the better design.

Common interview mistakes

Common mistakes include storing the whole record in one JSON document; putting foreign keys, money, dates, status values, or frequently queried fields only inside JSON; using a text column when the database has a suitable native JSON type; accepting arbitrary keys and types; relying only on PHP validation; assuming every database supports the same JSON syntax; confusing a JSON string with extracted text; comparing numbers or dates as strings; indexing every possible path; using an index expression that does not match the query; failing to inspect the query plan; claiming an index guarantees a fast query; interpolating untrusted values, identifiers, sort directions, or paths into SQL; claiming prepared statements protect dynamic identifiers; reading and rewriting the whole document without concurrency control; keeping transactions open too long; assuming transactions automatically prevent lost updates; making universal claims about partial-update storage behavior; changing the document shape without versioning; running one large blocking migration; and leaving data in JSON after it has become relational or independently queried.

Interview tip

Lead with the boundary between flexible record-owned attributes and relational data. Then explain validation, typed extraction, selective indexing, query-plan verification, PDO value binding, concurrent updates, versioned migrations, and the exact signals that tell you to move data into columns or related tables.

Interviewer may ask next
When should a value inside a JSON document be moved to a normal column or related table?

Move it to a typed column when it becomes required, frequently filtered or sorted, used in joins or reports, subject to uniqueness, or important enough to need clear database constraints and statistics. Move it to a related table when it represents repeated entities with their own identity, lifecycle, relationships, foreign keys, or independent updates.

How would you safely change the JSON document shape in production?

Add a schema version and first deploy code that can read both the old and new shapes. Then change writers to produce the new shape and migrate existing rows in controlled, restartable batches. Monitor errors, locks, transaction-log growth, replication delay, and query behavior. Keep rollback compatibility until all rows are converted, then remove the old reader and obsolete indexes or fields in a later deployment.

69. How would you design a reliable database retry strategy for transient failures in PHP?Sql / DatabaseHard

Question Details

Explain which failures are retryable, transaction boundaries, idempotency, exponential backoff with jitter, retry limits, deadlocks, connection loss, and how to avoid duplicating writes.

Short Interview Answer (30-60 seconds)

I retry only known temporary errors and retry the entire transaction, not an individual statement. I use capped exponential backoff with jitter, a small attempt and time limit, fresh connections when needed, and idempotency keys with unique constraints. An uncertain commit result is reconciled instead of blindly retried.

Detailed Explanation

See the Code while reading this explanation.

The question asks how a PHP program should react when saving or reading information fails for a short time. A good design should try again only when the problem may disappear, stop after a small number of attempts, and avoid creating the same record twice. It should also keep related changes together, pause between attempts, and handle cases where the program cannot tell whether the last save succeeded. The main goal is to recover from brief problems without hiding real errors, overloading the service, losing changes, or charging or updating someone more than once.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Which database engine and PDO driver are being used?
  • Is the operation a read, a write, or a multi-statement transaction?
  • Which SQLSTATE and driver codes does that database document as retryable?
  • Can the caller provide a stable idempotency key for each logical operation?
  • What maximum latency and retry count are acceptable?
  • Are external services or messages involved in the operation?
How would you design a reliable database retry strategy for transient failures in PHP? diagram
How to Explain It in an Interview

I would place the retry loop around one complete logical database operation. For a multi-statement write, that means retrying the entire transaction from the beginning. A transaction is a group of statements that either all commit or all roll back. Retrying only one failed statement can use stale data, skip earlier validation, or leave the business operation logically inconsistent.

I would classify errors before retrying. Retryable failures are temporary conditions for which a new attempt may succeed. Depending on the selected database and PDO driver, these can include deadlock victims, serialization failures, selected lock timeouts, temporary server unavailability, failover, or a connection failure that definitely occurred before the transaction could commit. The application should use a small, tested, database-specific allowlist based on SQLSTATE and driver error codes.

I would not retry permanent failures such as invalid SQL, missing database objects, invalid input, authentication failures, permission failures, unsupported operations, or ordinary constraint violations caused by the request. These conditions normally require a code, configuration, or data correction. Repeating them only adds latency and database load.

The transaction boundary and retry boundary should match. Each attempt must create or obtain a usable PDO connection, begin a new transaction, read any required current state, perform every related statement, and commit. If an attempt fails before a successful commit, the application should roll back when possible and discard an unusable connection. A transaction cannot be resumed on a replacement connection.

Deadlocks are a normal concurrency possibility. A deadlock occurs when transactions wait on one another in a cycle. The database normally chooses one transaction as the victim and aborts it. When the selected database reports a documented deadlock or serialization failure, the application should rerun the whole transaction because values read during the failed attempt may no longer be current.

A lock timeout requires database-specific treatment. Some databases abort the transaction, while others may cancel only the current statement and leave the transaction active. The PHP retry layer should still roll back the whole logical transaction before retrying so the next attempt starts from a clean and predictable state.

Connection failures need careful classification. If connecting fails before a transaction begins, retrying with a new connection may be safe. If the connection is lost during an uncommitted transaction, the application must discard that connection and start a new transaction. It should not assume that a transaction can continue after reconnecting.

A connection failure during or immediately after commit creates an ambiguous result. PHP may not know whether the server committed the transaction before the acknowledgement was lost. Blindly running the write again can duplicate it. A generic retry helper should therefore stop automatic retries when the commit outcome may be unknown and return an explicit ambiguous-result error for reconciliation.

Idempotency means that repeating the same logical request does not create an additional business effect. For important writes, I would assign a stable idempotency key to the request or job. That same key must be reused across every retry. I would store it in the database within the same transaction as the business change and protect it with a UNIQUE constraint.

The UNIQUE constraint is important because an application-level check is not enough. Two concurrent requests can both check for the key before either inserts it. The database constraint resolves that race safely. After an ambiguous result or a duplicate-key response, the application can query the stored operation by its key and return the previously recorded result when the stored request represents the same logical operation.

The idempotency record should normally include enough information to detect accidental reuse of the same key for a different request, such as a hash of the normalized operation input, its status, and a stored result reference. Retention must match the period during which clients, queues, or workers may resend the operation. Deleting keys too early can allow a delayed duplicate to run again.

I would also make individual statements naturally idempotent where practical. Setting a column to a specific value is easier to repeat safely than incrementing it. An optimistic concurrency update can include an expected version in its WHERE clause. If zero rows are updated, the application knows that another transaction changed the record and can decide whether to reload, retry, or report a conflict.

Prepared statements and parameter binding should still be used for values. They reduce SQL injection risk and avoid manual quoting, but they do not make an operation idempotent. They also do not make untrusted table names, column names, sort directions, or other SQL identifiers safe. Dynamic identifiers must come from an application-controlled allowlist.

Between attempts, I would use capped exponential backoff with jitter. Exponential backoff increases the maximum delay after each failure. Jitter chooses a random delay within that limit so many PHP workers do not retry at the same instant. The delay should have a maximum cap because very long sleeps are not useful inside a web request or worker.

I would enforce both a maximum attempt count and an overall elapsed-time limit. For example, an application might permit only a few attempts within its request or job deadline. The exact values depend on the database, workload, service-level objective, and caller timeout. Retries must stop early if there is not enough remaining time for another useful attempt.

Read retries also require judgment. A read outside a transaction is often safe to repeat, but retrying it may observe newer data. A read inside a transaction must be retried as part of the whole transaction. Reads with side effects, locking clauses, temporary state, or session-dependent behavior should not be assumed safe without reviewing the database semantics.

The transaction callback must not perform irreversible external actions that could run more than once, such as charging a card, sending an email, or publishing a message directly. A database rollback cannot undo those effects. A common solution is the transactional outbox pattern: store the business change and an outbox event in the same database transaction, then let a separate worker deliver that event idempotently.

The isolation level also affects retries. An isolation level controls which concurrent database changes a transaction can observe. Stronger isolation may prevent some anomalies but may increase blocking or serialization failures. I would choose the weakest isolation level that still protects the business rule and retry documented serialization failures from the beginning of the transaction.

Long transactions increase lock duration and conflict probability. I would keep transactions short, access rows in a consistent order where practical, use appropriate indexes so statements find and lock fewer rows, and avoid waiting for network services while a transaction is open. Retries are a recovery mechanism, not a replacement for fixing slow queries, missing indexes, excessive contention, or poor transaction design.

In production, I would record the operation name, attempt number, elapsed time, selected delay, SQLSTATE, driver code, transaction phase, and final outcome. I would not log credentials, sensitive values, or full SQL containing private data. Metrics should separate first-attempt success, successful retry, exhausted retries, permanent failures, deadlocks, connection failures, and ambiguous commits. A rising retry rate should trigger investigation because retries can temporarily hide a database or contention problem.

Key Insight / Why This Solution Works
  1. Define the complete logical database operation and its transaction boundary.
  2. Decide whether the operation can be repeated safely and require a stable idempotency key for important writes.
  3. Protect the idempotency key with a UNIQUE constraint in the same database that stores the business change.
  4. Build a tested retryable-error allowlist for the selected database and PDO driver.
  5. Start an overall elapsed-time budget and attempt counter.
  6. Create a usable PDO connection and begin a new transaction for the attempt.
  7. Re-read required state and execute the complete logical operation using bound parameters.
  8. Commit once all statements succeed.
  9. If a failure occurs, record whether it happened before commit, during commit, or after commit returned.
  10. Roll back an active transaction when possible and discard an unusable connection.
  11. If the commit result may be ambiguous, stop automatic retries and reconcile by the stable operation key.
  12. If the error is permanent or not explicitly allowlisted, throw it immediately.
  13. If no attempt or time budget remains, throw the final failure.
  14. Calculate a capped exponential delay and select random jitter within that cap.
  15. Sleep for the selected delay, create a fresh attempt, and rerun the entire transaction.
  16. Log and measure every retry outcome without exposing sensitive data.
Code
<?php

declare(strict_types=1);

final class RetryPolicy
{
    public function __construct(
        public readonly int $maxAttempts = 4,
        public readonly int $baseDelayMs = 50,
        public readonly int $maxDelayMs = 1_000,
        public readonly int $maxElapsedMs = 5_000,
    ) {
        if ($maxAttempts < 1) {
            throw new InvalidArgumentException('maxAttempts must be at least 1.');
        }

        if ($baseDelayMs < 0) {
            throw new InvalidArgumentException('baseDelayMs cannot be negative.');
        }

        if ($maxDelayMs < $baseDelayMs) {
            throw new InvalidArgumentException(
                'maxDelayMs must be greater than or equal to baseDelayMs.'
            );
        }

        if ($maxElapsedMs < 1) {
            throw new InvalidArgumentException('maxElapsedMs must be positive.');
        }
    }
}

final class AmbiguousCommitException extends RuntimeException
{
    public function __construct(
        public readonly PDOException $databaseException,
    ) {
        parent::__construct(
            'The transaction commit result is unknown; reconcile by idempotency key.',
            0,
            $databaseException,
        );
    }
}

/**
 * Run one complete logical transaction with bounded retries.
 *
 * @template T
 * @param Closure(): PDO $connectionFactory
 * @param Closure(PDO): T $operation
 * @param Closure(PDOException): bool $isRetryable
 * @param Closure(PDOException): bool $isAmbiguousCommitFailure
 * @return T
 * @throws PDOException
 * @throws AmbiguousCommitException
 */
function runRetriedTransaction(
    Closure $connectionFactory,
    Closure $operation,
    Closure $isRetryable,
    Closure $isAmbiguousCommitFailure,
    RetryPolicy $policy = new RetryPolicy(),
): mixed {
    $startedAtNs = hrtime(true);

    for ($attempt = 1; $attempt <= $policy->maxAttempts; $attempt++) {
        $pdo = null;
        $commitStarted = false;

        try {
            $pdo = $connectionFactory();
            $pdo->beginTransaction();

            $result = $operation($pdo);

            $commitStarted = true;
            $pdo->commit();

            return $result;
        } catch (PDOException $exception) {
            if (
                $commitStarted
                && $isAmbiguousCommitFailure($exception)
            ) {
                $pdo = null;
                throw new AmbiguousCommitException($exception);
            }

            if ($pdo instanceof PDO) {
                try {
                    if ($pdo->inTransaction()) {
                        $pdo->rollBack();
                    }
                } catch (PDOException) {
                    // The failed connection is discarded below.
                }
            }

            $pdo = null;

            $elapsedMs = intdiv(
                hrtime(true) - $startedAtNs,
                1_000_000,
            );

            $hasAnotherAttempt = $attempt < $policy->maxAttempts;

            if (
                !$hasAnotherAttempt
                || $elapsedMs >= $policy->maxElapsedMs
                || !$isRetryable($exception)
            ) {
                throw $exception;
            }

            $exponent = min($attempt - 1, 20);
            $exponentialLimitMs = $policy->baseDelayMs * (2 ** $exponent);
            $delayCapMs = min(
                $policy->maxDelayMs,
                $exponentialLimitMs,
            );

            // Full jitter chooses a random delay between zero and the cap.
            $delayMs = $delayCapMs > 0
                ? random_int(0, $delayCapMs)
                : 0;

            $remainingMs = $policy->maxElapsedMs - $elapsedMs;

            if ($delayMs >= $remainingMs) {
                throw $exception;
            }

            usleep($delayMs * 1_000);
        } catch (AmbiguousCommitException $exception) {
            throw $exception;
        } catch (Throwable $exception) {
            if ($pdo instanceof PDO) {
                try {
                    if ($pdo->inTransaction()) {
                        $pdo->rollBack();
                    }
                } catch (PDOException) {
                    // Preserve the original non-database exception.
                }
            }

            throw $exception;
        }
    }

    throw new LogicException('The retry loop ended unexpectedly.');
}

/**
 * Create a non-persistent PDO connection for the current attempt.
 * Credentials should come from protected application configuration.
 */
$connectionFactory = static function (): PDO {
    $dsn = getenv('DATABASE_DSN');
    $username = getenv('DATABASE_USER');
    $password = getenv('DATABASE_PASSWORD');

    if ($dsn === false || $dsn === '') {
        throw new RuntimeException('DATABASE_DSN is required.');
    }

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

/**
 * These are examples only. Configure one tested allowlist for the actual
 * database and PDO driver rather than combining unrelated database rules.
 */
$isRetryable = static function (PDOException $exception): bool {
    $sqlState = is_string($exception->getCode())
        ? $exception->getCode()
        : '';

    $driverCode = $exception->errorInfo[1] ?? null;

    $configuredSqlStates = [
        // Add documented deadlock or serialization SQLSTATE values here.
    ];

    $configuredDriverCodes = [
        // Add documented driver-specific temporary error codes here.
    ];

    return in_array($sqlState, $configuredSqlStates, true)
        || in_array($driverCode, $configuredDriverCodes, true);
};

/**
 * Configure only errors that can lose the commit acknowledgement while the
 * server may still have committed. Deadlock or serialization-abort errors do
 * not belong here when the selected database guarantees transaction rollback.
 */
$isAmbiguousCommitFailure = static function (
    PDOException $exception,
): bool {
    $sqlState = is_string($exception->getCode())
        ? $exception->getCode()
        : '';

    $driverCode = $exception->errorInfo[1] ?? null;

    $configuredAmbiguousSqlStates = [
        // Add tested connection-loss SQLSTATE values for the actual driver.
    ];

    $configuredAmbiguousDriverCodes = [
        // Add tested driver codes that can make commit acknowledgement unknown.
    ];

    return in_array($sqlState, $configuredAmbiguousSqlStates, true)
        || in_array($driverCode, $configuredAmbiguousDriverCodes, true);
};

// The application supplies an operation callback that performs every related
// statement through the PDO instance passed to it. Important writes must store
// a stable idempotency key under a UNIQUE constraint in the same transaction.
// When AmbiguousCommitException is caught, reconnect and query by that key
// before deciding whether any new write is necessary.
Why Interviewers Ask This

This question tests whether the candidate can distinguish temporary database failures from permanent errors, choose the correct transaction and retry boundaries, handle PDO connections safely, prevent duplicate writes, and reason about deadlocks, lock conflicts, backoff, retry limits, ambiguous commit results, database constraints, external side effects, and production observability.

Common interview mistakes

Common mistakes include retrying every PDOException; treating permanent errors as temporary; using the same retry codes for every database driver; retrying only the failed statement instead of the whole logical transaction; trying to continue a transaction on a replacement connection; immediately retrying without backoff; omitting jitter so workers retry together; allowing unlimited attempts; ignoring the caller's time budget; blindly repeating a write after an uncertain commit; generating a new idempotency key for each attempt; checking for duplicates without a UNIQUE constraint; reusing one key for different request data; deleting idempotency records too early; performing external side effects inside a retried transaction; keeping transactions open during network calls; swallowing the final exception; assuming prepared statements provide idempotency; and using retries to hide missing indexes, long transactions, or excessive lock contention.

Interview tip

Start with the central rule: retry the complete transaction only for an explicit database-specific temporary-error allowlist. Then explain bounded backoff with jitter, fresh transaction state, idempotency keys with unique constraints, and why an uncertain commit must be reconciled instead of automatically retried. Finish with external-side-effect handling and observability.

Interviewer may ask next
What should the PHP application do if the connection is lost while PDO is committing the transaction?

It should treat the outcome as unknown because the server may have committed before the acknowledgement was lost. The generic retry loop must not automatically repeat the write. The application should reconnect, query by the stable idempotency key, verify that the stored request matches the original operation, and return the stored result if it exists. It should perform a new write only when reconciliation establishes that the original operation did not commit.

Why is an application-level duplicate check insufficient without a database UNIQUE constraint?

Two concurrent requests can both check for an idempotency key before either request inserts it, so both may conclude that the key is unused. A UNIQUE constraint makes the database resolve this race atomically. One transaction succeeds, while the other can read the existing operation and return the same logical result instead of creating another business effect.

70. How would you migrate a large production table with minimal downtime from a PHP application?Sql / DatabaseHard

Question Details

Describe an expand-and-contract migration, dual-compatible application releases, online schema change options, backfilling in batches, validation, rollback, and observability.

Short Interview Answer (30-60 seconds)

I would use an expand-and-contract migration: add the new structure, deploy PHP code compatible with both schemas, backfill in small resumable batches, validate the data, switch reads gradually, monitor production, and remove the old structure only after the rollback window has passed.

Detailed Explanation

This question asks how to change a very large collection of live information without making the website unavailable for a long time. The safe approach is to make several small changes instead of replacing everything at once. The old and new forms should work together while existing information is copied gradually. The team should check that nothing is missing or changed incorrectly, watch the website for problems, and keep a safe way to return to the earlier version. The old form should be removed only after the new one has worked reliably.

Useful clarifying questions:

Useful Questions to Ask the Interviewer
  • Which database engine and version are used?
  • How large is the table, and what are its normal read and write rates?
  • What maximum lock time or downtime is acceptable?
  • Is the change adding a column, changing a type, adding an index, splitting a table, or replacing a table?
  • Can the PHP application temporarily read and write both old and new structures?
  • Are replicas, online schema-change tools, backups, and point-in-time recovery available?
  • Are there foreign keys, triggers, generated columns, scheduled jobs, or external consumers that depend on the table?
How would you migrate a large production table with minimal downtime from a PHP application? diagram
How to Explain It in an Interview

I would use an expand-and-contract migration so that every intermediate database and application state remains compatible.

1. Understand the current workload

Before changing the table, I would inspect its row count, physical size, write rate, important queries, indexes, constraints, foreign keys, triggers, long-running transactions, replication topology, storage headroom, and database-engine capabilities. I would define acceptable limits for lock time, query latency, replication lag, error rate, and migration duration.

The exact database engine and version matter because the same ALTER TABLE operation may be metadata-only in one version but may rebuild or lock the table in another.

2. Test the migration safely

I would test the proposed change against production-like data and workload. The test should measure execution time, lock behavior, temporary storage, transaction-log or binary-log growth, replica lag, and the effect on important query plans.

Testing with a tiny development table is not enough because many locking and storage problems appear only at production scale.

3. Expand the schema

I would first add the new column, index, table, or relationship without renaming or removing the existing structure. This is the expand phase.

The change must be backward compatible because old and new PHP processes can run at the same time during a rolling deployment. For example, when replacing a column, I could add the replacement column as nullable, or with a safe default when that is appropriate, instead of immediately dropping or renaming the original column.

Adding a default is not automatically safe on every database version. I would verify whether it is metadata-only or requires rewriting existing rows.

4. Select the schema-change mechanism

If the database supports the required change through native online or instant DDL with acceptable locking and resource use, I would prefer that simpler option. However, I would not assume that an operation described as online takes no locks. A brief metadata lock may still be required, and a long-running transaction can delay that lock and cause queued requests.

If native DDL would rebuild or block the large table for too long, I would evaluate a proven online schema-change tool. Such a tool commonly creates a shadow table, copies rows incrementally, captures concurrent changes, and performs a short final rename or swap.

Before using it, I would verify its compatibility with the exact database version, primary keys, foreign keys, triggers, generated columns, replication, storage capacity, and failure-recovery procedure. A shadow-table approach can temporarily require storage close to the size of the original table plus indexes and logs.

5. Deploy dual-compatible PHP code

The first PHP release must work with both the old and new schemas. Database access should be centralized in a repository, service, or other controlled data-access layer so that compatibility logic is not duplicated throughout the application.

For a column replacement, the application could initially continue reading the old column while writing both the old and new columns. For a table split, it could write the required records to both structures.

When both writes occur in the same database and must succeed together, I would place them in one transaction. The transaction should be short and should include only the statements that must be atomic. If the writes cross databases or external services, a normal database transaction cannot make them atomic; I would instead use a reliable outbox, event processing, reconciliation, or another explicit consistency design.

Retries must be idempotent. An idempotent operation can be repeated without creating duplicate or conflicting results. Unique constraints, stable operation identifiers, conditional updates, or upserts can help enforce that behavior.

6. Backfill existing rows in batches

After new writes are being captured correctly, I would copy or transform historical rows in small, restartable batches.

I would paginate using a stable indexed key, normally the primary key, rather than increasingly large OFFSET values. Keyset pagination such as selecting rows where the primary key is greater than the last processed key avoids repeatedly scanning and discarding earlier rows.

Each batch should:

  • Select a bounded primary-key range.
  • Update only rows that still require migration.
  • Commit after the batch.
  • Store a durable checkpoint.
  • Retry transient failures with a limit and backoff.
  • Record rows that require manual investigation.

I would avoid one transaction for the complete table because it can hold locks for too long, create large undo or transaction logs, delay cleanup, increase replica lag, and make failure recovery expensive.

Batch size should be adaptive. I would reduce or pause the backfill when query latency, lock waits, CPU, I/O, log growth, disk use, or replication lag exceeds an agreed threshold.

7. Prevent races with live writes

The backfill must not overwrite a newer value written by the PHP application.

For a simple additive column, the worker may update only rows where the new column is still null. For transformed or mutable data, I could use a version number, updated timestamp, source-value comparison, or optimistic conditional update. When a conditional update affects zero rows, the worker should reread or defer that record rather than overwrite it blindly.

The backfill should be idempotent so that restarting a completed batch produces the same final state.

8. Validate the result

I would validate throughout the migration and again before switching reads.

Useful checks include:

  • Number of rows eligible, processed, skipped, and failed.
  • Null, invalid, or out-of-range values.
  • Duplicate values before adding a unique constraint.
  • Foreign-key and other constraint violations.
  • Grouped counts and aggregates between old and new structures.
  • Checksums calculated over stable ranges.
  • Exact comparisons for sampled or high-value records.
  • Application-level comparisons between old and new read results.

Validation itself must not overload production. I would run checks in bounded indexed ranges, throttle them, or use a sufficiently current replica when the check does not require the primary database's latest state. Replica-based validation must account for replication lag.

9. Switch reads gradually

After the backfill is complete and validation passes, I would deploy PHP code that can read from the new structure. I would place the read switch behind a feature flag or controlled rollout when possible.

I could begin with internal traffic or a small percentage of requests and compare application errors, returned values, latency, query plans, database load, and important business results. If the new path fails, I can switch reads back to the old structure while the old data is still being maintained.

A fallback read from the old field can be useful during transition, but it should be temporary and observable. Otherwise, missing backfilled values may remain hidden indefinitely.

10. Stop old writes

Once the new read path is stable, I would deploy another release that stops writing to the old structure. Before doing so, I would verify that no older PHP instance, queue worker, scheduled command, report, maintenance script, or external integration still requires it.

I would then observe the system for an agreed rollback window. During that period, I would continue validation and keep the old structure available unless there is a strong reason not to.

11. Contract the schema

Only after the rollback window has passed would I remove the old column, table, index, trigger, compatibility code, fallback logic, and feature flags. This is the contract phase.

The destructive cleanup should be a separate deployment. Dropping a large column or index can still consume resources or require locks, depending on the database engine and version, so I would test and monitor the cleanup operation as carefully as the expansion.

12. Define rollback for every phase

Rollback is not one universal command. It depends on the migration phase:

  • Before backfilling, stop the rollout and leave the unused additive structure in place if removing it is risky.
  • During backfilling, pause the worker and continue reading from the old structure.
  • After switching reads, disable the feature flag or deploy the earlier compatible read path.
  • After stopping old writes, restore dual writes only if the old data is still sufficiently current or can be reconciled.
  • After deleting the old structure, rollback may require a reverse migration, backup restore, point-in-time recovery, and reconciliation of later writes.

A backup is essential, but restoring a very large table is not an instant rollback. The recovery time, recovery-point objective, and treatment of writes made after the backup must be understood before the migration starts.

13. Monitor and control the operation

I would create dashboards and alerts before starting the migration. Important signals include:

  • Application error rate and request latency.
  • Database query latency and connection usage.
  • Lock waits, deadlocks, and transaction duration.
  • CPU, memory, I/O, free disk space, and temporary-space use.
  • Transaction-log, redo-log, write-ahead-log, or binary-log growth as applicable.
  • Replica lag and replica errors.
  • Backfill throughput, remaining rows, retries, and failed records.
  • Validation mismatches.
  • Old-path and new-path usage.

The worker should have a safe pause control, rate limits, bounded retries, clear logs, and durable checkpoints. I would define automatic or manual stop conditions before the migration begins.

The main principle is to move the database and PHP application through reversible, compatible states. Minimal downtime comes from avoiding one large destructive cutover, not from assuming that every database operation is completely lock-free.

Technical Approach
  1. Identify the exact schema change, database engine and version, table size, traffic pattern, dependencies, and acceptable operational limits.
  2. Test the DDL, backfill, validation, and rollback procedures with production-like data and workload.
  3. Add the new structure without deleting or renaming the old structure.
  4. Use native online DDL when its measured locking and resource use are acceptable; otherwise evaluate a compatible online schema-change tool.
  5. Deploy PHP code that works with both schemas during rolling deployment.
  6. Begin atomic dual writes when both values are in the same database, or use explicit asynchronous consistency and reconciliation when they are not.
  7. Backfill historical rows in small, idempotent, resumable batches ordered by a stable indexed key.
  8. Protect live updates with conditional writes, versions, timestamps, or source-value comparisons.
  9. Throttle or pause work based on latency, locks, CPU, I/O, disk use, log growth, and replica lag.
  10. Validate row counts, constraints, aggregates, checksums, sampled records, and application-level results.
  11. Switch reads gradually behind a feature flag while preserving the old read path for rollback.
  12. Stop old writes only after all PHP processes, workers, jobs, and integrations are compatible.
  13. Observe the new path during a defined rollback window.
  14. Remove the old structure and compatibility code in a separate contract deployment.
Practical Insights

A migration that examines or rewrites every row performs roughly O(N) data work for N rows. Creating a replacement table or rebuilding indexes may also require O(N) reading and writing. This does not mean the elapsed time is predictable, because indexes, row size, storage speed, concurrent traffic, logging, replication, and throttling strongly affect it. A shadow-table migration may temporarily require space close to the original table size plus new indexes and database logs. Keyset batching uses a small bounded amount of application memory because the worker processes one batch at a time. A database engine may still use substantial cache, temporary space, undo, redo, or transaction-log storage. Smaller batches reduce lock duration and operational pressure but usually increase total migration time and coordination overhead. Dual-compatible code also adds temporary maintenance complexity.

Why Interviewers Ask This

This question evaluates whether the candidate can coordinate application deployments and database changes without causing a long outage or corrupting data. It tests backward-compatible schema design, online DDL, batched backfills, consistency during dual writes, validation, rollback planning, locking awareness, replication impact, and production observability. It also checks whether the candidate understands that a large migration should usually be divided across multiple reversible releases rather than performed as one destructive deployment.

Common interview mistakes

Common mistakes include running an untested blocking ALTER TABLE directly in production; assuming online DDL never takes locks; renaming or dropping a field before every PHP process is compatible; adding a default without checking whether it rewrites the table; using one transaction for the complete backfill; using large OFFSET pagination; scanning without an appropriate index; allowing the backfill to overwrite newer application writes; performing non-atomic dual writes without reconciliation; retrying non-idempotent operations; switching reads before validation is complete; hiding missing migrated values behind a permanent fallback; running expensive validation queries without throttling; ignoring long transactions, foreign keys, triggers, generated columns, replicas, log growth, and disk capacity; treating a backup restore as an immediate rollback; and combining expansion, cutover, and destructive cleanup in one deployment.

Interview tip

Explain the migration as a sequence of compatible states: measure and test, expand, deploy dual-compatible PHP code, backfill, validate, switch reads gradually, observe, stop old writes, and contract. Mention race prevention, rollback by phase, locking, replication lag, disk use, and pause controls. Say minimal downtime rather than guaranteed zero downtime because a metadata lock or final table swap may still be required.

Interviewer may ask next
How would you prevent the backfill from overwriting a newer value written by the PHP application?

I would make each update conditional and idempotent. For example, the worker could update only when the new column is null and the source version or updated timestamp still matches the value it originally read. If the condition fails, the worker should reread or defer the row rather than overwrite it. When related writes are in the same database, I would keep them in one short transaction. Stable checkpoints and safe retries would allow the worker to resume without duplicating or corrupting data.

When would you choose native online DDL instead of an online schema-change tool?

I would choose native online or instant DDL when the exact database version supports the required operation and testing shows acceptable lock time, runtime, storage use, log growth, and replication impact. I would consider an online schema-change tool when native DDL would rebuild or block the table for too long. Before using the tool, I would verify support for the table's primary key, foreign keys, triggers, generated columns, replication setup, available disk space, final swap behavior, failure recovery, and rollback procedure.

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.