Connecting to MySQL directly in plain PHP, without a framework's ORM in the way, is worth understanding even in a Laravel-based career — PDO is the modern, recommended extension for this, and prepared statements are the actual fix for SQL injection, not an optional refinement.
Connecting with PDO
<?php
$host = 'localhost';
$db = 'myapp';
$user = 'myapp_user';
$password = 'secure_password';
try {
$pdo = new PDO("mysql:host={$host};dbname={$db};charset=utf8mb4", $user, $password);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die('Connection failed: ' . $e->getMessage());
}
PDO::ATTR_ERRMODE set to ERRMODE_EXCEPTION is worth making explicit in example code because query failures then become exceptions you can handle consistently. On PHP 8.0 and newer this is already PDO's default error mode; older PHP releases defaulted to silent error reporting, which is why older tutorials often treat this attribute as mandatory.
Running a simple query
$stmt = $pdo->query('SELECT id, name FROM products');
$products = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($products as $product) {
echo $product['name'] . PHP_EOL;
}
The critical mistake: building queries with string concatenation
// NEVER DO THIS — vulnerable to SQL injection
$name = $_POST['name'];
$stmt = $pdo->query("SELECT * FROM products WHERE name = '{$name}'");
If $name contains something like ' OR '1'='1, this concatenated query's logic changes entirely — this is the exact mechanism behind a SQL injection attack, and it applies to any user-supplied value inserted directly into a query string, not just an obviously malicious-looking one.
The fix: prepared statements with bound parameters
$name = $_POST['name'];
$stmt = $pdo->prepare('SELECT * FROM products WHERE name = ?');
$stmt->execute([$name]);
$products = $stmt->fetchAll(PDO::FETCH_ASSOC);
A prepared statement keeps the SQL template separate from the values bound into its placeholders. Used correctly, that means input such as $name is handled as data instead of being concatenated into the SQL syntax — the essential protection against the injection technique above.
Using named placeholders for readability
$stmt = $pdo->prepare('SELECT * FROM products WHERE category = :category AND price < :maxPrice');
$stmt->execute(['category' => 'electronics', 'maxPrice' => 100]);
Inserting a row
$stmt = $pdo->prepare('INSERT INTO products (name, price) VALUES (:name, :price)');
$stmt->execute(['name' => 'Wireless Mouse', 'price' => 29.99]);
$newId = $pdo->lastInsertId();
Why this matters even for a Laravel developer
Eloquent and the query builder handle parameter binding automatically behind the scenes — but understanding what's actually happening underneath (as shown here with raw PDO) is exactly why DB::raw() and manually concatenated raw SQL fragments inside an Eloquent query are just as vulnerable to injection as the plain PHP mistake above, despite being written inside a Laravel application.
Put connection options in the constructor when you want one explicit policy
$pdo = new PDO(
"mysql:host={$host};dbname={$db};charset=utf8mb4",
$user,
$password,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
This makes the connection's error and fetch behavior visible at construction time. Setting utf8mb4 in the DSN is equally important — otherwise the connection can negotiate a charset that does not faithfully represent all Unicode characters even if the table itself uses a Unicode-capable collation.
Prepared statements protect values, not SQL identifiers
Placeholders can stand in for data values, but not for a table name, column name, sort direction, or arbitrary SQL fragment. This does not work as a safe dynamic sort:
$stmt = $pdo->prepare('SELECT * FROM products ORDER BY ?');
$stmt->execute([$_GET['sort']]);
The placeholder is treated as a value, not a column identifier. For dynamic identifiers, use a fixed allow-list and select the SQL fragment yourself:
$allowed = ['name', 'price', 'created_at'];
$sort = $_GET['sort'] ?? 'name';
if (!in_array($sort, $allowed, true)) {
$sort = 'name';
}
$stmt = $pdo->query("SELECT id, name, price FROM products ORDER BY {$sort}");
The interpolation is safe here because $sort can only be one of the literal strings defined by the application, not arbitrary request data.
Transactions are the next primitive worth learning
If several writes form one business operation, prepared statements alone are not enough — you also need atomicity. beginTransaction(), commit(), and rollBack() are the PDO-level equivalents of the transaction helpers a framework later wraps. Understanding both layers makes it much easier to recognize when an ORM operation still needs a transaction around it.