Home PHP CRUD with PHP and MySQL: Build, Query and Protect a Real Database

CRUD with PHP and MySQL: Build, Query and Protect a Real Database

In Plain English 🔥
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.
⚡ Quick Answer
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.

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.php · PHP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
<?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 MismatchIf 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.

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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
<?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 bindParamUse `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.

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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
<?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 WHEREMySQL 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.
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

  • 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.
  • 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().
  • 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.
  • 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.

⚠ Common Mistakes to Avoid

  • Mistake 1: 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(). There is no legitimate reason to concatenate user input into a SQL string.
  • Mistake 2: 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.
  • Mistake 3: 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.

Interview Questions on This Topic

  • QWhat 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()?
  • QWhat is the difference between PDO::FETCH_ASSOC and PDO::FETCH_OBJ, and when would you choose one over the other in a real application?
  • QIf 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?

Frequently Asked Questions

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.

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.

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.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousPHP PDO — PHP Data ObjectsNext →SQL Injection Prevention in PHP
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged