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.
thecodeforge.io
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);
functiongetPDO(): 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 = newPDO($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.thrownewRuntimeException('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.
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 tabledeclare(strict_types=1);
require_once 'db_connection.php';
$pdo = getPDO();
// ─────────────────────────────────────────────// CREATE — Insert a new article// ─────────────────────────────────────────────functioncreateArticle(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// ─────────────────────────────────────────────functiongetArticleById(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// ─────────────────────────────────────────────functiongetArticlesByAuthor(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.
thecodeforge.io
Crud Php Mysql
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 tabledeclare(strict_types=1);
require_once 'db_connection.php';
$pdo = getPDO();
// ─────────────────────────────────────────────// UPDATE — Change the title and body of an article by ID// ─────────────────────────────────────────────functionupdateArticle(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// ─────────────────────────────────────────────functiondeleteArticle(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.// ─────────────────────────────────────────────functiondeleteArticleWithAudit(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();
returnfalse;
}
$pdo->commit(); // Both steps succeeded — make it permanentreturntrue;
} 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.
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 productiondeclare(strict_types=1);
// ─────────────────────────────────────────────// Output escaping — use this everywhere you display user-generated content// ─────────────────────────────────────────────functione(string $value): string
{
returnhtmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// ─────────────────────────────────────────────// Authorization check — example for delete// ─────────────────────────────────────────────functioncanUserDeleteArticle(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// ─────────────────────────────────────────────functionhandleDeleteRequest(PDO $pdo, int $currentUserId, int $articleId): string
{
if (!canUserDeleteArticle($pdo, $currentUserId, $articleId)) {
returnjson_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();
returnjson_encode(['error' => 'Article not found.']);
}
$pdo->commit();
returnjson_encode(['success' => true]);
} catch (PDOException $e) {
$pdo->rollBack();
// Log errorreturnjson_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.
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.
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.
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.
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.
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;
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
Aspect
MySQLi (Procedural)
PDO
Database support
MySQL only
12+ databases (MySQL, PostgreSQL, SQLite, etc.)
Prepared statements
Supported — verbose syntax
Supported — cleaner named placeholder syntax
Default fetch mode
Requires explicit flag each time
Configurable once at connection, inherited by all queries
Named placeholders
Not supported — positional ? only
Supported — :name syntax improves readability
Error handling
Must check return values manually
Throws PDOException — works with try/catch naturally
Switching databases later
Requires full rewrite
Change DSN string — queries stay the same
Recommended for new projects
No — legacy preference only
Yes — PDO is the modern standard
⚙ Quick Reference
7 commands from this guide
File
Command / Code
Purpose
db_connection.php
declare(strict_types=1);
Setting Up Your Database and PDO Connection the Right Way
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.
Q02 of 03SENIOR
What is the difference between PDO::FETCH_ASSOC and PDO::FETCH_OBJ, and when would you choose one over the other in a real application?
ANSWER
FETCH_ASSOC returns each row as an associative array keyed by column name (e.g., $row['title']). FETCH_OBJ returns each row as a stdClass object with properties (e.g., $row->title). Use FETCH_ASSOC when you need to manipulate the data with array functions like array_map() or when working with JSON serialization that expects arrays. Use FETCH_OBJ when you prefer object syntax and have no need for array functions. Performance differences are negligible. In a production CRUD API, FETCH_ASSOC is more common because prepared responses often need array access.
Q03 of 03SENIOR
If rowCount() returns 0 after an UPDATE query, does that definitely mean the record does not exist? How would you distinguish between 'row not found' and 'row found but nothing changed', and why does that distinction matter for an API response?
ANSWER
No, rowCount() returning 0 after an UPDATE can mean either: (1) the WHERE clause matched no rows (record does not exist) or (2) the WHERE clause matched a row but the SET values were identical to the existing values, and the database optimized away the update (no changes made). To distinguish, first run a SELECT with the same WHERE clause. If the SELECT returns a row, then the record exists but the data was unchanged. If the SELECT returns nothing, the record does not exist. This distinction matters because for an API, returning a 404 (not found) vs a 200 with a message 'no changes needed' provides correct semantics to the client. A frontend that expects a success might otherwise display an error incorrectly.
01
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()?
SENIOR
02
What is the difference between PDO::FETCH_ASSOC and PDO::FETCH_OBJ, and when would you choose one over the other in a real application?
SENIOR
03
If rowCount() returns 0 after an UPDATE query, does that definitely mean the record does not exist? How would you distinguish between 'row not found' and 'row found but nothing changed', and why does that distinction matter for an API response?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Should I use MySQLi or PDO for PHP and MySQL CRUD operations?
Use PDO for all new projects. It supports named placeholders which are more readable, lets you configure error handling and fetch mode once at connection time, and is database-agnostic — so if you ever switch from MySQL to PostgreSQL, your query code stays the same. MySQLi is only worth using if you are maintaining existing code that already uses it.
Was this helpful?
02
What is the difference between fetch() and fetchAll() in PDO?
fetch() returns a single row as an array (or false if no rows matched) and moves the internal cursor forward — ideal when you expect exactly one result, like a lookup by ID. fetchAll() returns all matching rows as an array of arrays — ideal for lists. fetchAll() on a large result set loads everything into memory at once, so for very large datasets consider looping with fetch() instead.
Was this helpful?
03
How do I handle errors in PDO without showing raw database messages to users?
Set PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION at connection time so PDO throws a PDOException on any failure. Wrap your database calls in try/catch blocks — log the full exception details server-side (to a file or monitoring service) and show the user a generic, friendly error message. Never echo $e->getMessage() directly to the browser in production, as it can expose table names, column names and query structure to attackers.
Was this helpful?
04
What is the difference between bindValue() and bindParam() in PDO?
bindValue() binds the actual value of a variable at the time bindValue() is called. If the variable changes later, the new value is not used. bindParam() binds a reference to the variable, meaning the value is evaluated at execute() time. This is useful when looping and you want each iteration to use the current variable value. For most CRUD operations, bindValue() is safer and more predictable.
Was this helpful?
05
Should I use PDO::ERRMODE_EXCEPTION or PDO::ERRMODE_WARNING?
Use ERRMODE_EXCEPTION in both development and production. ERRMODE_WARNING will issue a PHP warning but continue execution, which can leave your application in an inconsistent state or silently return corrupted data. Exceptions force you to handle errors explicitly with try/catch, making your code more reliable.