Senior 7 min · March 06, 2026

CRUD PHP MySQL — WHERE Clause Disasters & Safe Deletes

A commented-out WHERE clause deleted an entire customer table.

N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Drawn from code that ran under real load.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • CRUD stands for Create, Read, Update, Delete — the four operations that power nearly every web app
  • PDO with prepared statements is the only safe way to interact with MySQL from PHP
  • Every input must be bound via placeholders, never concatenated into SQL strings
  • Always configure charset=utf8mb4, ERRMODE_EXCEPTION, and EMULATE_PREPARES=false at connection time
  • Use transactions when multiple queries must all succeed or all fail together
  • The most dangerous mistake: omitting WHERE on UPDATE/DELETE — it wipes all rows instantly
✦ Definition~90s read
What is CRUD with PHP and MySQL?

CRUD (Create, Read, Update, Delete) operations in PHP with MySQL are the backbone of virtually every dynamic web application — from e-commerce carts to SaaS dashboards. The problem is that most developers learn these operations through quick tutorials that skip the critical safety nets: parameterized queries, transaction boundaries, and defensive WHERE clauses.

Imagine a school notice board.

This article focuses on the two most dangerous operations — UPDATE and DELETE — where a missing WHERE clause or a poorly constructed one can wipe out an entire table in milliseconds. You'll learn why raw SQL strings are a liability, how PDO prepared statements prevent injection attacks, and why you should never trust user input directly in your queries, even for "safe" operations like reads.

Beyond the basics, this article covers the real-world patterns that separate production-grade code from hobby projects. You'll see how to structure your PDO connection with proper error modes and character sets, how to use transactions to roll back partial failures during multi-step operations, and how to implement soft deletes as a safety net against accidental data loss.

We'll also address the common pitfalls: using LIMIT without ORDER BY in deletes, forgetting to bind parameters correctly, and exposing raw database errors to users. By the end, you'll have a repeatable pattern for CRUD that handles edge cases like concurrent writes and malformed input without breaking your application.

Plain-English First

Imagine a school notice board. Someone pins a new notice (Create), you walk up and read it (Read), the teacher crosses out a typo and rewrites it (Update), then at the end of term the caretaker takes it down (Delete). Every app that stores information — Instagram, your bank, a todo list — is just doing those four things over and over. CRUD is just the official name for that loop.

Almost every application you have ever used runs on CRUD. When you post a photo, that is a Create. When your feed loads, that is a Read. When you edit your bio, that is an Update. When you delete a tweet you regret, that is a Delete. PHP has been powering this cycle on the web since the late 1990s, and MySQL remains one of the most widely deployed relational databases on the planet. Understanding how they work together is not just academic — it is the backbone of nearly every commercial PHP project in existence.

The problem most tutorials create is teaching you the mechanics without the safety net. They show you how to fire a query but not how to stop an attacker from rewriting it. They show you how to fetch a row but not how to handle the moment the row does not exist. Real production code needs both: the functionality and the guardrails, written together from the start.

By the end of this article you will have a fully working CRUD system built on PDO with prepared statements, proper error handling, and the mental model to extend it to any project. You will know not just what each operation does but why PDO exists, why prepared statements are non-negotiable, and exactly where junior developers leave dangerous holes.

Why CRUD PHP MySQL Is Where Most Data Loss Happens

CRUD PHP MySQL is the pattern of Create, Read, Update, Delete operations executed from PHP against a MySQL database. The core mechanic is mapping HTTP methods (POST, GET, PUT, DELETE) to SQL statements (INSERT, SELECT, UPDATE, DELETE), typically through a PDO or mysqli connection. In practice, the critical property is that every operation depends on a WHERE clause to scope its effect — a missing or incorrect WHERE turns an UPDATE into a mass overwrite and a DELETE into a table wipe. Production systems fail not because the SQL is wrong, but because the WHERE clause is absent or uses unescaped user input, leading to catastrophic data loss at O(n) cost where n is the entire table. Use this pattern when you need persistent state with relational integrity; avoid it when you need eventual consistency or high write throughput — that's a job for a message queue or a NoSQL store. The reason it matters: 90% of junior-level data incidents in web apps trace back to a broken WHERE in a CRUD operation.

The WHERE Clause Is Your Only Shield
A DELETE without a WHERE clause is not a bug — it's a production incident. Always validate the WHERE condition before executing destructive SQL.
Production Insight
A team ran a nightly cleanup script that deleted old orders. A schema migration renamed the column from order_date to created_at, but the PHP code still referenced order_date. The WHERE clause evaluated to NULL for every row, MySQL treated WHERE NULL as false, and the DELETE removed zero rows — but the next week, a different script with a missing WHERE wiped 2 million rows because the cleanup never ran. Rule of thumb: every destructive SQL statement must be wrapped in a transaction with a row-count check before commit.
Key Takeaway
A missing WHERE clause is a data-loss event, not a code smell.
Always use prepared statements with parameterized queries — never concatenate user input into SQL.
Test destructive operations in a transaction and verify the affected row count before committing.
CRUD PHP MySQL — Safe Deletes & WHERE Clause THECODEFORGE.IO CRUD PHP MySQL — Safe Deletes & WHERE Clause Flow from connection to safe deletion with error handling PDO Connection Setup Use DSN with charset utf8mb4 Prepared Statements Bind parameters to avoid injection WHERE Clause Validation Ensure WHERE is present in DELETE Transaction Wrapping Begin/commit for atomic operations Safe Delete Execution Check affected rows and rollback ⚠ Missing WHERE in DELETE wipes entire table Always validate WHERE clause before executing THECODEFORGE.IO
thecodeforge.io
CRUD PHP MySQL — Safe Deletes & WHERE Clause
Crud Php Mysql

Setting Up Your Database and PDO Connection the Right Way

Before writing a single query you need two things: a database table with real structure, and a connection object you can trust. Most tutorials bury the connection in a one-liner and move on. That is a mistake, because the connection is where security and reliability are either baked in or left out.

PDO — PHP Data Objects — is the modern way to talk to MySQL from PHP. Unlike the old mysql_* functions (removed in PHP 7) or even MySQLi, PDO gives you a single consistent API that works across multiple database engines. More importantly, it makes prepared statements the natural default, which is your primary defence against SQL injection.

The key settings to configure at connection time are the charset (always utf8mb4 — the older utf8 cannot store emoji or certain Unicode characters), the error mode (set it to exceptions so failures are loud and catchable, not silent), and the emulated prepares flag (turn it off so the database driver handles the actual parameterisation, not PHP). Get these three right once in your connection file and every query you write inherits those protections automatically.

db_connection.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?php
// db_connection.php — reusable PDO connection factory
// Call getPDO() anywhere in your app to get a safe, configured connection.

declare(strict_types=1);

function getPDO(): PDO
{
    // Store credentials in environment variables, never hard-coded in production.
    // For local development a constants file is acceptable.
    $host     = 'localhost';
    $dbName   = 'forge_blog';   // The database we created in MySQL
    $username = 'root';         // Replace with a least-privilege DB user in prod
    $password = '';             // Never commit real passwords to version control

    // utf8mb4 supports the full Unicode range including emoji — utf8 does not.
    $dsn = "mysql:host={$host};dbname={$dbName};charset=utf8mb4";

    $options = [
        // Throw exceptions on any DB error — no silent failures.
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,

        // Return rows as associative arrays by default — e.g. $row['title']
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,

        // Disable emulated prepares so MySQL handles parameter binding natively.
        // This closes an entire class of edge-case injection vulnerabilities.
        PDO::ATTR_EMULATE_PREPARES   => false,
    ];

    try {
        $pdo = new PDO($dsn, $username, $password, $options);
        return $pdo;
    } catch (PDOException $e) {
        // In production, log $e->getMessage() and show a generic error page.
        // Never expose raw database errors to the browser.
        throw new RuntimeException('Database connection failed. Check credentials.');
    }
}

// --- Companion SQL to create the table (run once in MySQL) ---
// CREATE DATABASE forge_blog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
// USE forge_blog;
// CREATE TABLE articles (
//     id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
//     title      VARCHAR(255)  NOT NULL,
//     body       TEXT          NOT NULL,
//     author     VARCHAR(100)  NOT NULL,
//     created_at TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
//     updated_at TIMESTAMP     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
// );
Output
// No visible output — this file defines a function.
// When getPDO() is called successfully, it returns a PDO object.
// On failure it throws: RuntimeException: Database connection failed. Check credentials.
Watch Out: Charset Mismatch
If you create your table with utf8mb4 but connect with utf8 (or forget the charset in the DSN altogether), you will get corrupted data for any character outside the basic Latin alphabet — including apostrophes in certain server configurations. Always match your DSN charset to your table collation.
Production Insight
When charset mismatch hits production, you don't get a SQL error — you get silent data corruption. The character 'é' becomes 'é'. Customers see scrambled names in emails. Debugging that wastes hours because everything looks fine in the PHP output until you examine the raw database bytes.
Always verify charset at connection time by running SHOW VARIABLES LIKE 'character_set%' immediately after connecting.
Rule: the DSN charset and the table collation must match exactly — both utf8mb4.
Key Takeaway
Configure three PDO options at connection time: ERRMODE_EXCEPTION, FETCH_ASSOC, EMULATE_PREPARES false.
These three settings make every subsequent query safer without extra effort.
The charset in your DSN must match the table collation — always use utf8mb4.
Choose Your Connection Configuration
IfYou need to connect to MySQL or a database that might be switched later (e.g., PostgreSQL)
UseUse PDO. It provides a consistent API across 12+ database engines.
IfYour project is legacy and already uses MySQLi extensively
UseStick with MySQLi — but ensure you still use prepared statements, not real_escape_string.
IfYou are building a new PHP project from scratch
UseUse PDO with ATTR_EMULATE_PREPARES => false for maximum security.

Create and Read — Inserting Records and Fetching Them Safely

Create and Read are the two operations you will write most often, so they deserve the most attention. The golden rule for Create is: never concatenate user input directly into a query string. Ever. Instead, use a prepared statement — you write the query with named placeholders like :title, then bind the actual values separately. The database driver handles escaping, not you.

For Read, the decisions are about scope: are you fetching one specific row by ID, or a filtered list? fetch() returns a single row (or false if nothing matched), while fetchAll() returns every matching row as an array of arrays. Both are useful; choosing the wrong one is a common source of bugs.

A subtlety beginners miss: execute() on a prepared statement does not return the inserted row — it returns a boolean. To get the ID of the row you just created, call $pdo->lastInsertId() immediately after. This ID is what you use to redirect the user to the new record's page, which is the standard Post-Redirect-Get pattern that prevents duplicate submissions on browser refresh.

articles_create_read.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
<?php
// articles_create_read.php — Create and Read operations for the articles table

declare(strict_types=1);

require_once 'db_connection.php';

$pdo = getPDO();

// ─────────────────────────────────────────────
// CREATE — Insert a new article
// ─────────────────────────────────────────────
function createArticle(PDO $pdo, string $title, string $body, string $author): int
{
    // Named placeholders (:title, :body, :author) keep the query readable
    // and completely separate structure from data — SQL injection impossible.
    $sql = 'INSERT INTO articles (title, body, author) VALUES (:title, :body, :author)';

    $statement = $pdo->prepare($sql);  // Parse the query structure once

    // bindValue sends the actual value to the driver with its type declared.
    // PDO::PARAM_STR tells the driver to treat it as a string, not an integer.
    $statement->bindValue(':title',  $title,  PDO::PARAM_STR);
    $statement->bindValue(':body',   $body,   PDO::PARAM_STR);
    $statement->bindValue(':author', $author, PDO::PARAM_STR);

    $statement->execute();  // Runs the query with the bound values

    // lastInsertId() returns the auto-incremented ID of the row just created.
    // Cast to int because it comes back as a string from the driver.
    return (int) $pdo->lastInsertId();
}

// ─────────────────────────────────────────────
// READ ONE — Fetch a single article by its ID
// ─────────────────────────────────────────────
function getArticleById(PDO $pdo, int $articleId): array|false
{
    $sql = 'SELECT id, title, body, author, created_at FROM articles WHERE id = :id LIMIT 1';

    $statement = $pdo->prepare($sql);
    $statement->bindValue(':id', $articleId, PDO::PARAM_INT); // PARAM_INT — type matters
    $statement->execute();

    // fetch() returns one row as an associative array, or false if no row matched.
    // Always check for false before using the result.
    return $statement->fetch();
}

// ─────────────────────────────────────────────
// READ MANY — Fetch all articles by a given author
// ─────────────────────────────────────────────
function getArticlesByAuthor(PDO $pdo, string $author): array
{
    $sql = 'SELECT id, title, author, created_at FROM articles WHERE author = :author ORDER BY created_at DESC';

    $statement = $pdo->prepare($sql);
    $statement->bindValue(':author', $author, PDO::PARAM_STR);
    $statement->execute();

    // fetchAll() returns every matching row — an empty array if none matched, never false.
    return $statement->fetchAll();
}

// ─────────────────────────────────────────────
// --- Demo execution ---
// ─────────────────────────────────────────────

// Create two articles
$firstId  = createArticle($pdo, 'Getting Started with PHP', 'PHP is a server-side language...', 'Ada Lovelace');
$secondId = createArticle($pdo, 'MySQL Indexing Deep Dive', 'Indexes speed up reads dramatically...', 'Ada Lovelace');

echo "Created articles with IDs: {$firstId} and {$secondId}\n\n";

// Read one back
$article = getArticleById($pdo, $firstId);

if ($article === false) {
    echo "Article not found.\n";
} else {
    echo "Title:  {$article['title']}\n";
    echo "Author: {$article['author']}\n";
    echo "Posted: {$article['created_at']}\n\n";
}

// Read all by that author
$authorArticles = getArticlesByAuthor($pdo, 'Ada Lovelace');

echo "Articles by Ada Lovelace:\n";
foreach ($authorArticles as $row) {
    echo "  [{$row['id']}] {$row['title']}\n";
}
Output
Created articles with IDs: 1 and 2
Title: Getting Started with PHP
Author: Ada Lovelace
Posted: 2024-11-14 09:23:41
Articles by Ada Lovelace:
[2] MySQL Indexing Deep Dive
[1] Getting Started with PHP
Pro Tip: bindValue vs bindParam
Use bindValue() when passing a literal or variable that won't change. Use bindParam() only when you're binding a reference that you want evaluated at execute() time — typically inside a loop. For most CRUD work, bindValue() is clearer and safer.
Production Insight
If you ever call lastInsertId() on a different PDO object than the one used for the INSERT, you'll get '0' — that's a common bug when using connection pooling or reconnecting after a timeout.
Also, fetch() returns false for no rows, not null. Many junior devs write if (!$article) which also catches empty arrays, but fetch() never returns an array — only false. The correct guard is strict comparison: $article === false.
Rule: always check fetch() return with === false and get the insert ID from the exact same PDO connection.
Key Takeaway
Use named placeholders with bindValue for every INSERT and SELECT that takes user input.
For reads: fetch() for single row, fetchAll() for lists — never assume the row exists.
The biggest hidden bug: calling lastInsertId() on the wrong PDO object returns 0.
Choose Your Read Method
IfYou expect exactly one row (by primary key lookup)
UseUse fetch(). Check for false to handle 'not found' case.
IfYou expect zero or more rows (list query)
UseUse fetchAll(). It returns an array — empty if no matches, never false.
IfYou are iterating over a very large result set (1M+ rows)
UseUse a loop with fetch() inside a while to avoid loading all rows into memory at once — fetchAll() would exceed memory limit.

Update and Delete — Modifying Records Without Destroying Your Data

Update and Delete are where the stakes get higher. A missing WHERE clause on either one is catastrophic — Update rewrites every row in the table, Delete wipes them all. This is not a hypothetical: it is one of the most common causes of data loss incidents in production systems.

The discipline to adopt is: always require a specific ID (or equivalent unique identifier) before executing an Update or Delete. Never build a generic 'update whatever matches' function that accepts arbitrary WHERE conditions from user input.

For Update, return the number of affected rows from rowCount() after executing. Zero affected rows does not always mean an error — it might mean the user submitted the form with identical values. Your application should distinguish between 'record not found' (the ID does not exist) and 'nothing changed' (the ID exists but the data was the same). The cleanest way to check existence is a separate getArticleById() call before the update.

For Delete, the same rowCount() check applies. Always give the calling code a boolean signal it can act on — redirect on success, show an error on failure.

articles_update_delete.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
<?php
// articles_update_delete.php — Update and Delete operations for the articles table

declare(strict_types=1);

require_once 'db_connection.php';

$pdo = getPDO();

// ─────────────────────────────────────────────
// UPDATE — Change the title and body of an article by ID
// ─────────────────────────────────────────────
function updateArticle(PDO $pdo, int $articleId, string $newTitle, string $newBody): bool
{
    // The WHERE id = :id clause is non-negotiable.
    // Without it, EVERY row in the table would be overwritten.
    $sql = 'UPDATE articles SET title = :title, body = :body WHERE id = :id';

    $statement = $pdo->prepare($sql);
    $statement->bindValue(':title', $newTitle,   PDO::PARAM_STR);
    $statement->bindValue(':body',  $newBody,    PDO::PARAM_STR);
    $statement->bindValue(':id',    $articleId,  PDO::PARAM_INT);

    $statement->execute();

    // rowCount() tells us how many rows were actually modified.
    // 0 can mean the ID didn't exist OR the data was identical — both are valid.
    // We return true if at least one row changed, false otherwise.
    return $statement->rowCount() > 0;
}

// ─────────────────────────────────────────────
// DELETE — Remove an article permanently by ID
// ─────────────────────────────────────────────
function deleteArticle(PDO $pdo, int $articleId): bool
{
    // Again, WHERE id = :id is the critical guard.
    // Omitting WHERE on a DELETE clears the entire table — instant data loss.
    $sql = 'DELETE FROM articles WHERE id = :id';

    $statement = $pdo->prepare($sql);
    $statement->bindValue(':id', $articleId, PDO::PARAM_INT);
    $statement->execute();

    // If rowCount() is 1, the row was found and deleted.
    // If it's 0, no row with that ID existed — caller can handle this case.
    return $statement->rowCount() === 1;
}

// ─────────────────────────────────────────────
// TRANSACTION EXAMPLE — Delete with safety net
// Use transactions when multiple queries must all succeed or all fail together.
// ─────────────────────────────────────────────
function deleteArticleWithAudit(PDO $pdo, int $articleId, string $deletedBy): bool
{
    try {
        $pdo->beginTransaction(); // Start — nothing commits until we say so

        // Step 1: Log the deletion intent before removing the data
        $logSql = 'INSERT INTO audit_log (action, target_id, performed_by) VALUES (:action, :target, :user)';
        $logStmt = $pdo->prepare($logSql);
        $logStmt->execute([
            ':action' => 'delete_article',
            ':target' => $articleId,
            ':user'   => $deletedBy,
        ]);

        // Step 2: Remove the article itself
        $deleted = deleteArticle($pdo, $articleId);

        if (!$deleted) {
            // Article didn't exist — roll back the audit log entry too
            $pdo->rollBack();
            return false;
        }

        $pdo->commit(); // Both steps succeeded — make it permanent
        return true;

    } catch (PDOException $e) {
        $pdo->rollBack(); // Something went wrong — undo everything
        throw $e;         // Re-throw so the caller can log or handle it
    }
}

// ─────────────────────────────────────────────
// --- Demo execution ---
// (Assumes articles with ID 1 and 2 exist from the previous example)
// ─────────────────────────────────────────────

// Update article 1
$wasUpdated = updateArticle($pdo, 1, 'Getting Started with PHP — Revised Edition', 'Updated body content here...');
echo $wasUpdated
    ? "Article 1 updated successfully.\n"
    : "Article 1: no changes made (identical data or ID not found).\n";

// Try updating an ID that doesn't exist
$missingUpdate = updateArticle($pdo, 9999, 'Ghost Article', 'This should not work.');
echo $missingUpdate
    ? "Unexpected: ghost article updated.\n"
    : "Article 9999 not found — nothing updated.\n";

// Delete article 2
$wasDeleted = deleteArticle($pdo, 2);
echo $wasDeleted
    ? "Article 2 deleted successfully.\n"
    : "Article 2 not found — nothing deleted.\n";
Output
Article 1 updated successfully.
Article 9999 not found — nothing updated.
Article 2 deleted successfully.
Watch Out: UPDATE and DELETE Without WHERE
MySQL will happily execute UPDATE articles SET title = 'Oops' with no WHERE clause — and overwrite every single row. Always test destructive queries on a development database first, and consider enabling MySQL's safe-updates mode (SET SQL_SAFE_UPDATES = 1) during development so the engine rejects keyless updates and deletes.
Production Insight
The most common production data loss I've seen: a developer ran a manual SQL script labelled 'clean_old_logs' that used a JOIN to filter rows, but the JOIN had a typo making it a cartesian product — the UPDATE touched every row.
Another: an engineer used updateArticle($pdo, 0, ...) without checking if $articleId was empty — and WHERE id = 0 matched no rows, but they expected an error. The app silently did nothing. Users saw stale data.
Rule: always validate the ID exists before updating, and always wrap destructive operations in transactions so you can rollback if something goes wrong.
Key Takeaway
Always write the WHERE clause first — before the SET or column names.
rowCount() == 0 is ambiguous for UPDATE — always check existence separately.
Use transactions for any multi-step destructive operation — a single rollback saves your data.
Handle rowCount() After Update or Delete
IfrowCount() > 0 after UPDATE or DELETE
UseThe operation succeeded. Redirect or show success message.
IfrowCount() == 0 after DELETE
UseThe record with that ID does not exist (or was already deleted). Return 404 or 'not found' response.
IfrowCount() == 0 after UPDATE
UseTwo possibilities: record does not exist, OR the new data is identical to the old data. Run a SELECT first to distinguish between them.

Transactions and Error Handling — Keeping Your Data Consistent

CRUD operations rarely happen in isolation. In a real application, creating an order might require inserting into three tables: orders, order_items, and payments. If any one of those inserts fails, the others must never commit. That's what database transactions are for.

PDO supports transactions with beginTransaction(), commit(), and rollBack(). They are not optional — they are the difference between atomic operations and corrupt data. Without a transaction, a failed second query leaves half-written data that can trigger cascading failures in downstream systems.

Error handling is equally critical. Setting PDO::ERRMODE_EXCEPTION means any failed query throws a PDOException immediately. You catch it, log the details (without exposing them to the user), and decide whether to retry, rollback, or return an error. Never let raw database errors reach the browser — they leak column names, table structures, and sometimes credentials.

transaction_example.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php
// transaction_example.php — Safe multi-table insert with rollback

declare(strict_types=1);

require_once 'db_connection.php';

function createOrder(PDO $pdo, int $userId, array $items): int
{
    // $items = [['product_id' => 1, 'quantity' => 2, 'price' => 19.99], ...]
    try {
        $pdo->beginTransaction();

        // Insert order header
        $orderSql = 'INSERT INTO orders (user_id, total) VALUES (:user_id, :total)';
        $orderStmt = $pdo->prepare($orderSql);
        $orderStmt->execute([
            ':user_id' => $userId,
            ':total'   => array_sum(array_column($items, 'price')),
        ]);
        $orderId = (int) $pdo->lastInsertId();

        // Insert each order item
        $itemSql = 'INSERT INTO order_items (order_id, product_id, quantity, price) VALUES (:order_id, :product_id, :quantity, :price)';
        $itemStmt = $pdo->prepare($itemSql);
        foreach ($items as $item) {
            $itemStmt->execute([
                ':order_id'   => $orderId,
                ':product_id' => $item['product_id'],
                ':quantity'   => $item['quantity'],
                ':price'      => $item['price'],
            ]);
        }

        $pdo->commit();
        return $orderId;

    } catch (PDOException $e) {
        $pdo->rollBack();
        // Log $e->getMessage() to monitoring system
        throw new RuntimeException('Order creation failed. Please try again.');
    }
}

// Usage
$items = [
    ['product_id' => 4, 'quantity' => 1, 'price' => 24.99],
    ['product_id' => 7, 'quantity' => 3, 'price' => 9.99],
];
try {
    $orderId = createOrder($pdo, 42, $items);
    echo "Order created with ID: $orderId\n";
} catch (RuntimeException $e) {
    echo "Failed to create order.\n";
    // Log actual error internally
}
Output
Order created with ID: 1001
Pro Tip: Keep Transactions Short
A transaction holds locks on the rows or tables it touches. Holding a transaction open while you make HTTP calls or run slow business logic causes lock contention and deadlocks. Keep the transaction scope to the minimal set of database operations, and commit as soon as possible.
Production Insight
I once saw a production outage caused by a transaction that contained a call to an external payment gateway API. The API was slow, the transaction held locks on the orders table for 30 seconds, and other requests started queuing up. Eventually the connection pool exhausted and the app went down.
Always separate external service calls from database transactions. Do the payment API call before starting the transaction, or after committing.
Rule: transactions are for database atomicity, not for distributed orchestration. Keep them tight and fast.
Key Takeaway
Wrap multi-query operations in beginTransaction/commit/rollback.
Never combine external API calls with database transactions — keep boundaries clean.
Always catch PDOException, log details server-side, and show generic errors to users.
When to Use Transactions
IfTwo or more INSERT/UPDATE/DELETE queries that must all succeed together
UseUse a transaction. If any single query fails, rollback all changes.
IfA single query that is logged to an audit table after success
UseConsider using a transaction to ensure the audit log is written even if the main operation commits — but often DELAYED logging can be handled separately.
IfOnly one query total (e.g., a simple INSERT or SELECT)
UseNo transaction needed unless you later need to expand to multiple queries.

Security Best Practices — Defend Against SQL Injection, XSS, and Data Exposure

CRUD operations are the primary attack surface of any PHP application. SQL injection is the most dangerous — an attacker who can inject SQL can read, modify, or delete any data in your database. Prepared statements with PDO are your first and most effective defence, but they are not enough on their own.

You also need to protect against Cross-Site Scripting (XSS) when displaying data that users submitted. Always escape output with htmlspecialchars($value, ENT_QUOTES, 'UTF-8') before rendering in HTML. Never trust data from the database — even if it was inserted via prepared statements, it could contain JavaScript that executes in other users' browsers.

Beyond injection, you must control access at the application level: don't let User A delete User B's articles. Implement authorization checks for every Update and Delete operation. Use session-based authentication and verify ownership or role before executing any destructive action.

secure_crud.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php
// secure_crud.php — Defensive patterns for CRUD in production

declare(strict_types=1);

// ─────────────────────────────────────────────
// Output escaping — use this everywhere you display user-generated content
// ─────────────────────────────────────────────
function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

// ─────────────────────────────────────────────
// Authorization check — example for delete
// ─────────────────────────────────────────────
function canUserDeleteArticle(PDO $pdo, int $userId, int $articleId): bool
{
    $sql = 'SELECT COUNT(*) FROM articles WHERE id = :id AND author_id = :user_id';
    $stmt = $pdo->prepare($sql);
    $stmt->execute([':id' => $articleId, ':user_id' => $userId]);
    return $stmt->fetchColumn() > 0;
}

// ─────────────────────────────────────────────
// Example secure delete endpoint logic
// ─────────────────────────────────────────────
function handleDeleteRequest(PDO $pdo, int $currentUserId, int $articleId): string
{
    if (!canUserDeleteArticle($pdo, $currentUserId, $articleId)) {
        return json_encode(['error' => 'Not authorized to delete this article.']);
    }

    try {
        $pdo->beginTransaction();
        // Assuming a deleteArticle function from earlier that returns bool
        $deleted = deleteArticle($pdo, $articleId);
        if (!$deleted) {
            $pdo->rollBack();
            return json_encode(['error' => 'Article not found.']);
        }
        $pdo->commit();
        return json_encode(['success' => true]);
    } catch (PDOException $e) {
        $pdo->rollBack();
        // Log error
        return json_encode(['error' => 'Deletion failed.']);
    }
}

// ─────────────────────────────────────────────
// Using output escaping in a view template (pseudo PHP)
// ─────────────────────────────────────────────
// <!-- article.php -->
// <h1><?= e($article['title']) ?></h1>
// <p><?= nl2br(e($article['body'])) ?></p>
// <small>By <?= e($article['author']) ?></small>
Output
// No visible output — these are reusable functions.
// When used with PDO prepared statements and proper escaping,
// the application is resilient to SQL injection, XSS, and unauthorized access.
Watch Out: Output Escaping is Not Optional
Even if your data comes from the database via prepared statements, if a user entered <script>alert('xss')</script> into a field, it will be stored and later executed in every browser that views it. Always escape for the output context: htmlspecialchars() for HTML, filter_var() for URLs with FILTER_SANITIZE_URL, and json_encode() for JSON APIs.
Production Insight
A team I worked with had a massive data breach because they used htmlspecialchars() on output but forgot to apply it to the User-Agent and Referer headers logged from HTTP requests. Those headers weren't user input in the traditional sense — but they were controlled by the client. Attackers injected JavaScript into the logs, then sent a link to an admin that viewed the logs. That admin's session was hijacked.
Sanitize everything that goes into the database, and escape everything that comes out.
Rule: never trust data you didn't generate yourself — not even from your own database.
Key Takeaway
Prepared statements stop SQL injection; htmlspecialchars stops XSS; ownership checks stop unauthorized edits.
Sanitize all inputs, escape all outputs — even data from your own database.
The cost of a single unescaped field is a full data breach.
Which Security Measure to Use
IfYou are inserting or updating any data from a user
UseUse PDO prepared statements with bound parameters — never concatenate input into SQL.
IfYou are displaying data that users entered
UseUse htmlspecialchars() or a template engine with auto-escaping (like Twig).
IfYou need to restrict Create/Update/Delete by user ownership
UseImplement authorization checks before each operation — verify the user owns the record or has a required role.

Before You Touch a Single Line of Code: The Real Prerequisites

You need a database server. You need PHP 8.1+ with PDO and the MySQL driver enabled. You need a text editor that doesn't suck. That's the bare minimum.

What nobody tells you: you need to understand what happens when your database connection fails. Not "oh I'll just catch the exception" — you need to know what the raw PDO error looks like, what the MySQL error log says, and how to test your connection before you write a single query. I've seen production apps vomit white screens because the dev didn't verify the socket path.

Run php -m | grep pdo and verify the driver is loaded. Open your MySQL console and confirm you can authenticate. Create the database manually first. Then and only then write the connection code. This isn't paranoia — it's the difference between a 30-minute setup and a 3-hour debugging session.

If you're on shared hosting, check if PDO is even available. Some hosts still run PHP 5.6 with mysql_* functions. Don't assume. Verify.

PreFlightCheck.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — php tutorial
// Verify your PHP environment before trusting a single line

// 1. Check PDO driver availability
echo 'PDO drivers: ' . implode(', ', PDO::getAvailableDrivers()) . PHP_EOL;
// Output: PDO drivers: mysql, sqlite, pgsql

// 2. Test raw socket connection (will explode if wrong)
try {
    $pdo = new PDO('mysql:host=127.0.0.1;port=3306;dbname=crud_demo', 'root', '');
    echo 'Connection OK' . PHP_EOL;
    // Output: Connection OK
} catch (PDOException $e) {
    echo 'FAIL: ' . $e->getMessage() . PHP_EOL;
    // Output: FAIL: SQLSTATE[HY000] [1049] Unknown database 'crud_demo'
    exit(1);
}

// 3. Verify database exists
$stmt = $pdo->query('SELECT DATABASE()');
echo 'Active DB: ' . $stmt->fetchColumn() . PHP_EOL;
// Output: Active DB: crud_demo
Output
PDO drivers: mysql
Connection OK
Active DB: crud_demo
Production Trap:
Never use localhost in production connection strings. Use 127.0.0.1 or the actual socket path. localhost forces PHP to use a Unix socket, which breaks when your hosting moves MySQL to a different host. I've debugged this at 2 AM. It's not fun.
Key Takeaway
Verify your PDO driver, MySQL socket, and database existence before you write a single query.

The OOP Pitfall: Writing Classes That Look Clean but Leak Data

Competitors tell you to use OOP because it's "cleaner" and "reusable." They're half right. The problem is they show you a User class that glues SQL, HTML escaping, and business logic into one file. That's not OOP — that's procedural code wearing a trench coat.

Here's what matters: your database class should only handle database operations. It should not echo HTML. It should not validate form data. It should take parameters, execute a query, and return raw data. Nothing else. Single responsibility principle isn't a buzzword — it's what keeps your code from turning into a tangled mess when you need to switch from MySQL to PostgreSQL next year.

Create a Database class that handles connection and query execution. Then create a UserRepository that uses that database object. The UserRepository returns User objects (or arrays). Your controller then decides what to render. That's real separation of concerns.

Most production exploits I've seen came from devs who thought they were being "safe" by wrapping everything in a class, but still concatenated user input directly into queries. A class doesn't save you from stupidity — parameterized queries do.

RepositoryPattern.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// io.thecodeforge — php tutorial
// Clean separation — Database does one thing

class Database {
    private PDO $connection;

    public function __construct(string $dsn, string $user, string $pass) {
        $this->connection = new PDO($dsn, $user, $pass, [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
    }

    public function query(string $sql, array $params = []): PDOStatement {
        $stmt = $this->connection->prepare($sql);
        $stmt->execute($params);
        return $stmt;
    }
}

class UserRepository {
    public function __construct(private Database $db) {}

    public function findById(int $id): array {
        $stmt = $this->db->query('SELECT * FROM users WHERE id = ?', [$id]);
        $user = $stmt->fetch();
        return $user ?: [];
    }
}

// Usage — no HTML, no validation, just data
$db = new Database('mysql:host=127.0.0.1;dbname=crud_demo', 'root', '');
$repo = new UserRepository($db);
print_r($repo->findById(1));
// Output: Array ( [id] => 1 [name] => Alice [email] => alice@example.com )
Output
Array
(
[id] => 1
[name] => Alice
[email] => alice@example.com
)
Senior Shortcut:
Don't build your own ORM. Use a small library like Idiorm or just stick with raw PDO + a repository pattern. The moment you write $user->save() that also queries the database, you've coupled your domain layer to persistence. Keep them separate.
Key Takeaway
A class is not a security feature. Separation of concerns + parameterized queries are what protect your data.
● Production incidentPOST-MORTEMseverity: high

The Missing WHERE Clause That Deleted an Entire Customer Table

Symptom
All customer-facing pages returned empty lists. Support tickets flooded in within 5 minutes. The production database had no recent backup.
Assumption
The engineer assumed the query had a WHERE clause because the script was labelled 'clean_inactive_customers.sql' and had been reviewed by a peer.
Root cause
The WHERE status = 'inactive' line was commented out during development testing and never restored. The script was run directly on the production server from a local terminal, bypassing the usual deployment pipeline.
Fix
Restored from a 6-hour old nightly backup. 4 hours of customer signups and orders were permanently lost. Implemented mandatory code review for any DML script and enforced use of transactions with ROLLBACK in all manual SQL executions.
Key lesson
  • Never run a DELETE or UPDATE query directly on production without wrapping it in a transaction with a targeted WHERE clause written and verified first.
  • Always enable MySQL's SQL_SAFE_UPDATES in your session — it refuses keyless updates and deletes.
  • Every destructive query should be reviewed by a second developer, even in emergencies.
Production debug guideCommon symptoms and the exact action to take when things break in production5 entries
Symptom · 01
SQLSTATE[HY000] [2002] Connection refused — cannot connect to the database
Fix
Check if MySQL is running: sudo systemctl status mysql. Then verify the host, port, and DSN string match the database server configuration. Test from command line: mysql -h host -u user -p.
Symptom · 02
A prepared statement executes without error but no rows are returned
Fix
Log the bound parameters and the query before running it. Then execute the same query manually with those values in the MySQL console. Commonly the WHERE clause is too specific or the data types don't match (e.g., comparing a string to an integer).
Symptom · 03
lastInsertId() returns 0 after INSERT
Fix
Make sure the table has an AUTO_INCREMENT primary key and you are calling lastInsertId() on the same PDO object that executed the INSERT. If you used a separate connection or closed the connection, the ID is lost.
Symptom · 04
UPDATE or DELETE returns rowCount() = 0 even though the record exists
Fix
Check that the value bound to the WHERE clause matches the actual field type. If the column is an integer and you bind a string with PARAM_STR, MySQL may perform implicit conversion and fail to match. Use PARAM_INT for numeric IDs.
Symptom · 05
execute() returns true but data is garbled or missing characters (e.g., é becomes é)
Fix
The charset in your DSN does not match the table collation. Both must use utf8mb4. Connect with charset=utf8mb4 and ensure the table uses COLLATE utf8mb4_unicode_ci. Reconnect and re-insert the data.
★ CRUD Debugging: Quick Commands That Find the ProblemWhen a CRUD operation behaves unexpectedly, run these commands first instead of guessing.
SQL injection suspected — attacker bypassed authentication
Immediate action
Take the app offline or put up a maintenance page. Check Apache/Nginx access logs for suspicious patterns like ' OR '1'='1.
Commands
tail -100 access.log | grep -E "('.+?OR|--|UNION)"
Check which controllers received non-numeric input for numeric fields: grep -E "id=\D" access.log
Fix now
Rollback to the last known good deployment of the PHP code. The vulnerable code must have concatenated user input into SQL. Replace all queries with prepared statements.
After heavy traffic, INSERT queries fail with "table is full"+
Immediate action
Check disk space with `df -h`. If the disk is full, run `du -sh /var/lib/mysql` to confirm.
Commands
sudo du -sh /var/lib/mysql/dbname/ ; df -h /var/lib/mysql
Check MySQL error log: `sudo tail -100 /var/log/mysql/error.log`
Fix now
Free up space by archiving old data or extending the volume. Once space is available, MySQL automatically resumes accepting writes.
Page loads slowly when fetching a list of 1000+ records+
Immediate action
Check query execution time with `EXPLAIN` on the slow query.
Commands
EXPLAIN SELECT * FROM articles ORDER BY created_at DESC;
Check slow query log: `sudo tail -100 /var/log/mysql/mysql-slow.log`
Fix now
Add an index on the created_at column: CREATE INDEX idx_articles_created_at ON articles(created_at);
Duplicate key error on INSERT for a non-unique column+
Immediate action
Check the table definition to see if there is a UNIQUE constraint on a column that was not expected.
Commands
SHOW CREATE TABLE articles;
Check recent schema changes: `SHOW WARNINGS;`
Fix now
If the UNIQUE constraint is incorrect, remove it with ALTER TABLE articles DROP INDEX constraint_name;. If it's intentional, use INSERT ... ON DUPLICATE KEY UPDATE.
CRUD Operations with PHP and MySQL: The Complete Picture
AspectMySQLi (Procedural)PDO
Database supportMySQL only12+ databases (MySQL, PostgreSQL, SQLite, etc.)
Prepared statementsSupported — verbose syntaxSupported — cleaner named placeholder syntax
Default fetch modeRequires explicit flag each timeConfigurable once at connection, inherited by all queries
Named placeholdersNot supported — positional ? onlySupported — :name syntax improves readability
Error handlingMust check return values manuallyThrows PDOException — works with try/catch naturally
Switching databases laterRequires full rewriteChange DSN string — queries stay the same
Recommended for new projectsNo — legacy preference onlyYes — PDO is the modern standard

Key takeaways

1
PDO with ATTR_EMULATE_PREPARES set to false is the correct, modern way to connect PHP to MySQL
the three options at connection time (errmode, fetch mode, emulated prepares) define the safety and behaviour of every query you write.
2
Prepared statements are not optional performance overhead
they are a structural separation of query logic from data, which is the only reliable defence against SQL injection. bindValue() with an explicit PDO::PARAM_* type is clearer and more predictable than passing an array to execute().
3
Every UPDATE and DELETE must have a WHERE clause targeting a specific, unique identifier. Zero rows affected from rowCount() is ambiguous
it means either the row does not exist or the data was identical — and good application logic handles both cases explicitly.
4
Transactions are the mechanism for keeping two or more related database operations atomic. If any step fails, rollBack() undoes all of them
without transactions, a half-completed multi-query operation can leave your data in an inconsistent state permanently.
5
Security is layered
prepared statements for SQL injection, htmlspecialchars() for XSS, and ownership checks for authorization. Skipping any one layer leaves your CRUD application vulnerable to a class of attacks.

Common mistakes to avoid

4 patterns
×

Using string concatenation instead of prepared statements

Symptom
Code works fine until a user enters ' OR '1'='1 in a field and suddenly reads data they should not, or worse, drops a table.
Fix
Always use prepare() with named placeholders and bindValue(). Never concatenate user input into a SQL string. There is no legitimate reason to do so.
×

Forgetting to check `fetch()` return value before accessing array keys

Symptom
PHP throws Warning: Undefined index: title or a fatal error when trying to access $article['title'] on a false value.
Fix
Always guard with if ($article === false) before using the result. fetchAll() is safer for lists since it returns an empty array, never false.
×

Omitting the WHERE clause on UPDATE or DELETE

Symptom
Every row in the table is modified or deleted — silent data destruction with no PHP error or warning.
Fix
Write the WHERE clause first, then fill in the rest of the query. Use MySQL's safe-updates mode in development, and always run untested destructive queries against a database dump, not live data.
×

Using `bindParam()` when `bindValue()` is appropriate

Symptom
Subtle bugs where the bound value doesn't evaluate until execute(), often causing loops to bind the reference to the last iteration's value.
Fix
Use bindValue() for most cases. Reserve bindParam() only when you need to bind a variable reference inside a loop and want the current value at execution time.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is SQL injection and how do prepared statements in PDO prevent it? ...
Q02SENIOR
What is the difference between PDO::FETCH_ASSOC and PDO::FETCH_OBJ, and ...
Q03SENIOR
If rowCount() returns 0 after an UPDATE query, does that definitely mean...
Q01 of 03SENIOR

What is SQL injection and how do prepared statements in PDO prevent it? Can you walk me through exactly what happens at the database driver level when you call prepare() followed by execute()?

ANSWER
SQL injection occurs when user input is concatenated into a SQL query string, allowing an attacker to alter the query structure. With prepared statements, prepare() sends the query template to the database server, which parses and compiles it with placeholders. execute() then sends the actual parameter values separately, as data, not SQL. The database engine treats the placeholders as data containers, never as executable code — even if a parameter contains malicious SQL, it cannot change the query structure because the query plan is already compiled. PDO, when emulated prepares are disabled, forces this real preparation. When enabled, PDO emulates the behaviour but still escapes values, though some edge cases may be missed.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Should I use MySQLi or PDO for PHP and MySQL CRUD operations?
02
What is the difference between fetch() and fetchAll() in PDO?
03
How do I handle errors in PDO without showing raw database messages to users?
04
What is the difference between bindValue() and bindParam() in PDO?
05
Should I use PDO::ERRMODE_EXCEPTION or PDO::ERRMODE_WARNING?
N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's PHP & MySQL. Mark it forged?

7 min read · try the examples if you haven't

Previous
PHP PDO — PHP Data Objects
3 / 6 · PHP & MySQL
Next
SQL Injection Prevention in PHP