Learning Objectives – after this chapter you will be able to:
- Explain SQL injection and demonstrate how concatenated queries can be attacked.
- Use prepared statements in procedural and object-oriented style: prepare, bind, execute, fetch.
- Choose the correct bind types
i,d,sandb, and useexecute_query()(PHP 8.2). - Group several statements into a transaction with commit and rollback.
- Store passwords safely with
password_hash()and build a secure registration and login.
SQL Injection
SQL injection is one of the most common and most dangerous attacks on web applications. It happens when user input is pasted directly into an SQL statement, so that the input can change the meaning of the statement.
The following login page checks the user name and password by building the SQL statement with string concatenation. (It also stores a plain-text password – a second mistake, corrected later.)
<?php
$conn = mysqli_connect("localhost", "root", "", "phpbook");
$user = $_POST['user'];
$pass = $_POST['pass'];
$sql = "SELECT * FROM users WHERE username = '$user' AND password = '$pass'";
echo "SQL sent to the server: " . htmlspecialchars($sql) . "<br>";
$result = mysqli_query($conn, $sql);
if (mysqli_num_rows($result) > 0) {
$row = mysqli_fetch_assoc($result);
echo "<b>Welcome {$row['username']} (role: {$row['role']})</b>";
} else {
echo "Invalid login";
}
?>SQL sent to the server: SELECT * FROM users WHERE username = 'admin' AND password = 'wrong' Invalid login
Now the attacker types ' OR '1'='1 as the password:
SQL sent to the server: SELECT * FROM users WHERE username = 'admin' AND password = '' OR '1'='1' Welcome admin (role: admin)
The quote in the input closed the string, and OR '1'='1' made the condition always true – the attacker is logged in as admin without knowing the password. Similar input can read other tables, change data or delete it.
Prepared Statements
A prepared statement separates the SQL code from the data:
- Prepare – the SQL is sent to the server with placeholders
?instead of values. The server parses and checks it. - Bind – the PHP variables are bound to the placeholders, together with their types.
- Execute – the values are sent separately. They are always treated as data, never as SQL code, so injection is impossible. The same prepared statement can be executed many times with different values, which is also faster.
Bind types
| Type | Meaning |
|---|---|
| i | integer |
| d | double (float / decimal) |
| s | string (also used for dates and decimals given as text) |
| b | blob (binary data, sent in packets) |
The type string has one letter per placeholder, e.g. "sis" for string, integer, string.
Prepared Statements – Procedural Style
| Function | Purpose |
|---|---|
| mysqli_prepare($conn, sql) | Prepares a statement with ? placeholders; returns a statement object. |
| mysqli_stmt_bind_param($stmt, types, vars...) | Binds variables to the placeholders. |
| mysqli_stmt_execute($stmt [, params]) | Executes the statement (PHP 8.1: values may be passed as an array). |
| mysqli_stmt_get_result($stmt) | Gets a result set (mysqli_result) from a SELECT. |
| mysqli_stmt_bind_result($stmt, vars...) / mysqli_stmt_fetch($stmt) | Binds result columns to variables / fetches the next row into them. |
| mysqli_stmt_affected_rows($stmt) / mysqli_stmt_insert_id($stmt) | Rows changed / last insert id. |
| mysqli_stmt_close($stmt) | Closes the statement. |
<?php
$conn = mysqli_connect("localhost", "root", "", "phpbook");
$stmt = mysqli_prepare($conn,
"SELECT username, role FROM users WHERE username = ? AND password = ?");
mysqli_stmt_bind_param($stmt, "ss", $_POST['user'], $_POST['pass']);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);
if ($row = mysqli_fetch_assoc($result)) {
echo "Welcome {$row['username']}";
} else {
echo "Invalid login – the injection attempt failed.";
}
mysqli_stmt_close($stmt);
mysqli_close($conn);
?>Invalid login – the injection attempt failed.
Inserting several rows with one prepared statement
<?php
$conn = mysqli_connect("localhost", "root", "", "phpbook");
$stmt = mysqli_prepare($conn, "INSERT INTO fees (roll, amount, paid_on) VALUES (?, ?, ?)");
mysqli_stmt_bind_param($stmt, "ids", $roll, $amount, $date); // bound by reference
$payments = [[101, 2250.00, "2026-07-01"], [102, 3000.00, "2026-07-05"], [101, 2250.00, "2026-08-01"]];
foreach ($payments as [$roll, $amount, $date]) {
mysqli_stmt_execute($stmt); // uses the current values
echo "Saved payment id " . mysqli_stmt_insert_id($stmt) . " for roll $roll<br>";
}
mysqli_stmt_close($stmt);
// SELECT with bind_result
$stmt = mysqli_prepare($conn, "SELECT paid_on, amount FROM fees WHERE roll = ? ORDER BY paid_on");
$r = 101;
mysqli_stmt_bind_param($stmt, "i", $r);
mysqli_stmt_execute($stmt);
mysqli_stmt_bind_result($stmt, $paidOn, $amt);
echo "<b>Payments of roll $r</b><br>";
while (mysqli_stmt_fetch($stmt)) {
echo "$paidOn : Rs. $amt<br>";
}
mysqli_stmt_close($stmt);
mysqli_close($conn);
?>Saved payment id 1 for roll 101 Saved payment id 2 for roll 102 Saved payment id 3 for roll 101 Payments of roll 101 2026-07-01 : Rs. 2250.00 2026-08-01 : Rs. 2250.00
mysqli_stmt_bind_param() binds references to the variables, so each execute sends the values the variables have at that moment.
Prepared Statements – Object-Oriented Style
| Method / property | Purpose |
|---|---|
| $conn->prepare(sql) | Prepares a statement; returns a mysqli_stmt object. |
| $stmt->bind_param(types, vars...) | Binds variables to the placeholders. |
| $stmt->execute([params]) | Executes (PHP 8.1: an array of values may be passed directly). |
| $stmt->get_result() | Returns a mysqli_result for a SELECT. |
| $stmt->bind_result(vars...) / $stmt->fetch() | Binds result columns / fetches the next row. |
| $stmt->affected_rows / $stmt->insert_id / $stmt->num_rows | Rows changed / last insert id / rows (after store_result()). |
| $stmt->close() | Closes the statement. |
| $conn->execute_query(sql, params) | PHP 8.2: prepares, binds and executes in one call; returns a result. |
<?php
$conn = new mysqli("localhost", "root", "", "phpbook");
$stmt = $conn->prepare("SELECT roll, name FROM students WHERE city = ? AND course_id = ?");
$stmt->bind_param("si", $_GET['city'], $_GET['course']);
$stmt->execute();
$result = $stmt->get_result();
echo "Found {$result->num_rows} student(s):<br>";
while ($row = $result->fetch_assoc()) {
echo "{$row['roll']} – {$row['name']}<br>";
}
$stmt->close();
// PHP 8.1: pass the values to execute() as an array – no bind_param needed
$stmt = $conn->prepare("UPDATE courses SET fee = ? WHERE course_name = ?");
$stmt->execute([4800, "Core PHP"]);
echo "execute([...]): {$stmt->affected_rows} course updated<br>";
// PHP 8.2: execute_query() does prepare + bind + execute in one call
$row = $conn->execute_query("SELECT course_name, fee FROM courses WHERE course_name = ?", ["Core PHP"])
->fetch_assoc();
echo "execute_query: {$row['course_name']} now costs Rs. {$row['fee']}";
$conn->close();
?>Found 2 student(s): 101 – Asha Verma 103 – Neha Joshi execute([...]): 1 course updated execute_query: Core PHP now costs Rs. 4800.00
A ? can stand only for a value – not for a table name, column name, ORDER BY direction or SQL keyword. If such parts must depend on user input, check them against a whitelist, e.g. $order = in_array($_GET['sort'], ['name', 'city']) ? $_GET['sort'] : 'name';
Transactions
A transaction groups several SQL statements into one unit of work: either all of them succeed (commit) or none of them takes effect (rollback). Transactions need a transaction-safe engine such as InnoDB. They provide the ACID guarantees – Atomicity, Consistency, Isolation and Durability.
| Step | Procedural | OOP |
|---|---|---|
| Start | mysqli_begin_transaction($c) | $c->begin_transaction() |
| Make permanent | mysqli_commit($c) | $c->commit() |
| Undo | mysqli_rollback($c) | $c->rollback() |
| Auto-commit on/off | mysqli_autocommit($c, false) | $c->autocommit(false) |
The example moves money from one account to another. Both updates must happen together: if the second one fails, the first must be undone.
<?php
$conn = new mysqli("localhost", "root", "", "phpbook");
$amount = (float) ($_GET['amount'] ?? 0);
function balances(mysqli $conn): string
{
$out = [];
foreach ($conn->query("SELECT account, balance FROM ledger") as $r) {
$out[] = "{$r['account']} = {$r['balance']}";
}
return implode(", ", $out);
}
echo "Before: " . balances($conn) . "<br>";
$conn->begin_transaction();
try {
$stmt = $conn->prepare("UPDATE ledger SET balance = balance + ? WHERE account = ?");
$stmt->execute([$amount, "Institute"]); // credit
$stmt->execute([-$amount, "Asha"]); // debit – fails if balance < 0
$conn->commit();
echo "Transfer of Rs. $amount committed.<br>";
} catch (mysqli_sql_exception $e) {
$conn->rollback();
echo "Transfer of Rs. $amount failed and was rolled back: " . $e->getMessage() . "<br>";
}
echo "After: " . balances($conn);
?>Before: Institute = 51000.00, Asha = 2000.00 Transfer of Rs. 5000 failed and was rolled back: CONSTRAINT `ledger.balance` failed for `phpbook`.`ledger` After: Institute = 51000.00, Asha = 2000.00
The first request transferred 1000 (Asha 3000 → 2000). The second asks for 5000; the credit to the institute was executed, but the debit violated the CHECK (balance >= 0) rule, so the whole transaction was rolled back and the institute's balance is unchanged.
The same in procedural style, used to record an admission and its first fee payment together:
<?php
$conn = mysqli_connect("localhost", "root", "", "phpbook");
mysqli_begin_transaction($conn);
try {
$s1 = mysqli_prepare($conn, "INSERT INTO students (roll, name, city, course_id) VALUES (?, ?, ?, ?)");
mysqli_stmt_execute($s1, [107, "Rohit Jain", "Bhopal", 2]);
$s2 = mysqli_prepare($conn, "INSERT INTO fees (roll, amount, paid_on) VALUES (?, ?, CURDATE())");
mysqli_stmt_execute($s2, [107, 3000]);
mysqli_commit($conn);
echo "Admission and first payment saved together.<br>";
} catch (mysqli_sql_exception $e) {
mysqli_rollback($conn);
echo "Nothing saved: " . $e->getMessage() . "<br>";
}
// try again with the same roll number – the duplicate makes the first INSERT fail
mysqli_begin_transaction($conn);
try {
mysqli_stmt_execute(mysqli_prepare($conn, "INSERT INTO fees (roll, amount, paid_on) VALUES (?, ?, CURDATE())"), [107, 500]);
mysqli_stmt_execute(mysqli_prepare($conn, "INSERT INTO students (roll, name) VALUES (?, ?)"), [107, "Duplicate"]);
mysqli_commit($conn);
} catch (mysqli_sql_exception $e) {
mysqli_rollback($conn);
echo "Second attempt rolled back: " . $e->getMessage() . "<br>";
}
$n = mysqli_fetch_row(mysqli_query($conn, "SELECT COUNT(*) FROM fees WHERE roll = 107"))[0];
echo "Fee records for roll 107: $n";
?>Admission and first payment saved together. Second attempt rolled back: Duplicate entry '107' for key 'roll' Fee records for roll 107: 1
The fee row of the second attempt was rolled back too, so roll 107 still has exactly one payment.
Storing Passwords Safely
Never store passwords in plain text, and never with md5() or sha1(). Use password_hash() – it generates a random salt and a slow bcrypt (or Argon2) hash – and check a login with password_verify(). The hash is about 60 characters long, so the column should be VARCHAR(255).
Secure Registration and Login (OOP)
<?php
$conn = new mysqli("localhost", "root", "", "phpbook");
$msg = "";
if ($_SERVER["REQUEST_METHOD"] == "POST") {
$user = trim($_POST["user"]);
$pass = $_POST["pass"];
if (!preg_match("/^[a-z0-9_]{3,30}$/i", $user) || strlen($pass) < 8) {
$msg = "User name 3–30 letters/digits, password at least 8 characters.";
} else {
try {
$hash = password_hash($pass, PASSWORD_DEFAULT);
$conn->execute_query("INSERT INTO users (username, password) VALUES (?, ?)", [$user, $hash]);
$msg = "Registered successfully. You can now log in.";
} catch (mysqli_sql_exception $e) {
$msg = ($e->getCode() == 1062) ? "This user name is already taken." : "Registration failed.";
}
}
}
?>
<form method="post" style="font-family:Arial;font-size:13px">
<?= htmlspecialchars($msg) ?><br>
User <input name="user" id="u"> Password <input type="password" name="pass" id="p">
<input type="submit" value="Register" id="r">
</form>Registered successfully. You can now log in. User Password
This user name is already taken. User Password
<?php
session_start();
$conn = new mysqli("localhost", "root", "", "phpbook");
$row = $conn->execute_query("SELECT id, username, password, role FROM users WHERE username = ?",
[$_POST["user"] ?? ""])->fetch_assoc();
if ($row && password_verify($_POST["pass"] ?? "", $row["password"])) {
session_regenerate_id(true);
$_SESSION["user_id"] = $row["id"];
$_SESSION["role"] = $row["role"];
echo "Login successful – welcome {$row['username']} ({$row['role']})";
} else {
echo "Invalid user name or password";
}
?>Login successful – welcome kavita (reader)
Invalid user name or password
The stored value is a hash, not the password:
MariaDB [phpbook]> SELECT username, LEFT(password, 29) AS start_of_hash, LENGTH(password) AS len FROM users WHERE username = 'kavita'; +----------+-------------------------------+-----+ | username | start_of_hash | len | +----------+-------------------------------+-----+ | kavita | $2y$10$UnTMvmel8SFIqt0nVdrJC. | 60 | +----------+-------------------------------+-----+ 1 row in set
Security Checklist for PHP and MySQLi
- Use prepared statements (or
execute_query()) for every query that contains outside data. - Validate input (types, ranges, formats – Chapters 12 and 21) and escape output with
htmlspecialchars(). - Hash passwords with
password_hash(); regenerate the session id after login. - Connect with a limited database user, not root; keep credentials outside
htdocs. - Show friendly error messages to users; log technical details (
display_errors = Offin production). - Use transactions when several statements must succeed or fail together.
Key Takeaways
- SQL injection happens when user input is concatenated into SQL;
' OR '1'='1can bypass a login. - Prepared statements send SQL and data separately: prepare → bind (
i d s b) → execute → fetch. - Procedural:
mysqli_prepare,mysqli_stmt_bind_param,mysqli_stmt_execute,mysqli_stmt_get_result. - OOP:
$conn->prepare(),$stmt->bind_param(),$stmt->execute(),$stmt->get_result(); PHP 8.2$conn->execute_query(). - Transactions:
begin_transaction(),commit(),rollback()– all or nothing (InnoDB). - Store passwords with
password_hash()and check them withpassword_verify().
Program File
Extract the ZIP into C:\xampp\htdocs. It creates the folder phpbook/ch36 used in the file labels above; open the programs at http://localhost/phpbook/… with Apache (and MySQL, where needed) started in the XAMPP Control Panel.
Question / Answer (15 questions)
MCQ Test : Check Your Understanding (5 questions)
Select an option and press Check Answer, or answer all questions and press Submit Test to see your score. Each answer comes with an explanation.
Bibliography/References
The notes on this page are the author’s own compilation from the e-book PHP: Basics to Advance. Every program was run on Apache with PHP 8 and MariaDB, and the output shown is the real result of that run. The works listed below were consulted and are cited for attribution and further reading. Product names and trademarks belong to their respective owners.
- The PHP Group. (n.d.). PHP Manual – Prepared Statements (MySQLi). https://www.php.net/manual/en/mysqli.quickstart.prepared-statements.php
- The PHP Group. (n.d.). PHP Manual – mysqli_stmt::bind_param. https://www.php.net/manual/en/mysqli-stmt.bind-param.php
- The PHP Group. (n.d.). PHP Manual – mysqli::execute_query. https://www.php.net/manual/en/mysqli.execute-query.php
- The PHP Group. (n.d.). PHP Manual – mysqli::begin_transaction. https://www.php.net/manual/en/mysqli.begin-transaction.php
- The PHP Group. (n.d.). PHP Manual – password_hash. https://www.php.net/manual/en/function.password-hash.php
- OWASP Foundation. (n.d.). SQL Injection Prevention Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html