Chapter 8
Chapter 8 — MySQL with Apache and PHP
Installing the PHP MySQL extension is straightforward. Using it correctly — with prepared statements, proper error handling, and credentials stored outside the web root — is where most PHP applications fall short. This chapter builds a solid, secure foundation for PHP-MySQL development: the connection setup, the credential storage pattern, and the query techniques you'll use on every project.
php-mysql on Ubuntu/Debian. Keeping database credentials out of the web root — the most important security rule. The PDO connection with all three essential options. Prepared statements for SELECT, INSERT, UPDATE, and DELETE. Error handling that logs without leaking. Transaction handling. A complete practical example.
The Three PHP MySQL APIs
PHP has three ways to talk to MySQL. Two of them are wrong choices for new code.
- Removed from PHP 7. Dead.
- No prepared statements → SQL injection by default
- Old tutorials still use it. Don't copy them.
- MySQL-specific. Won't work with PostgreSQL or SQLite.
- Prepared statements supported
- OOP and procedural interfaces — inconsistent
- Reasonable choice if you're sure you'll never switch databases
- Works with MySQL, PostgreSQL, SQLite, and more
- Clean, consistent OOP interface
- Named placeholders (
:name) — much more readable than? - Exception-based error handling
- This chapter uses PDO exclusively
Installing the PHP MySQL Extension
php-mysql without restarting Apache leaves the old PHP process running without the driver. The result is a cryptic "could not find driver" PDOException even though the package is installed. sudo systemctl restart apache2 every time you install or remove a PHP extension.
Credentials Outside the Web Root
This is the single most important security rule in PHP-MySQL development. Your database password must never be inside a directory that Apache can serve.
Why the web root is dangerous for credentials
.htaccess / Apache rule could cause PHP files to be served as plain text instead of executed. A misconfigured PHP install. A malicious include path. Apache directory listing with the wrong permissions. A future accidental git push to a public repository. A LFI (local file inclusion) vulnerability in another file on the same server.
The directory layout that protects credentials
Apache's DocumentRoot is /var/www/html — it serves everything inside that directory. Anything in /var/www/ but above /var/www/html/ is unreachable from the web, but still accessible to your PHP scripts with require.
The credentials file — db.php
/var/www/site1/config/, /var/www/site2/config/. Each PHP process only needs to read its own site's credentials, not all sites' credentials.
The PDO Connection
DSN — the data source name
The DSN is a string that describes the database connection. For MySQL it looks like this:
The three essential PDO options
→ ERRMODE_EXCEPTION
false on errors — and most PHP code doesn't check for false. With this option, any database error throws a PDOException which your try/catch can handle. The default (ERRMODE_SILENT) is dangerous.→ FETCH_ASSOC
$row[0] and $row['title']). FETCH_ASSOC returns named keys only — cleaner, smaller arrays, no accidental use of column position.→ false
false.The connection factory — db_connect.php
static variable: PHP creates a new process (or reuses a worker) per HTTP request. Within a single request, static $pdo ensures get_db() connects once and returns the same connection object on every subsequent call — no reconnection overhead. Between requests the connection is closed naturally. You don't need a connection pool in most PHP setups because PHP-FPM handles this at the process level.
Prepared Statements — Not Optional
A prepared statement separates the SQL structure from the data. MySQL receives the query template first, compiles it, then receives the values separately. The values are never interpreted as SQL — SQL injection is structurally impossible.
$sql = "SELECT * FROM books WHERE title = '" . $_GET['q'] . "'"; — this is SQL injection. If $_GET['q'] is ' OR '1'='1, the WHERE clause becomes always-true and returns every row. If it's '; DROP TABLE books; --, it drops the table. Always use prepared statements for any value that comes from outside your code: user input, URL parameters, form data, API responses, file contents.
SELECT — single row
SELECT — multiple rows
INSERT
UPDATE and DELETE
Reusing a prepared statement in a loop
Error Handling — Log, Don't Leak
With ERRMODE_EXCEPTION set, any database error throws a PDOException. The exception's getMessage() contains the SQL error — useful for debugging, but dangerous to display to users.
$e->getMessage() directly to the page is a reconnaissance gift.
Transaction Handling
When an operation involves multiple writes that must all succeed or all fail together — creating an order and deducting stock, for example — wrap them in a transaction.
PDOException is thrown mid-transaction and you don't roll back, InnoDB holds the locks until the connection closes (end of the PHP request) — potentially seconds. During that time every other query touching those rows is blocked. The rollBack() releases the locks immediately.
Practical Example — User Login
The classic PHP-MySQL pattern: check a submitted username/password against the database. This example shows the correct approach: no SQL injection, no timing attacks, no credential leaks.
utf8mb4 — The Only Correct Character Set
utf8 character set only stores 3-byte Unicode characters — it cannot store emoji, some Chinese characters, or other 4-byte code points. The correct character set is utf8mb4 (utf-8 with 4-byte support). Always use utf8mb4 in your DSN, on your database, and on your tables. Setting it in the PDO DSN ensures every string sent over the connection is interpreted correctly — no SET NAMES query needed.
Troubleshooting
pdo_mysql extension isn't loaded by the running Apache PHP. Steps: 1) Install: sudo apt install php-mysql. 2) Restart Apache: sudo systemctl restart apache2 — this is the most commonly skipped step. 3) Verify: run php -m | grep pdo_mysql from the shell, then also check via a phpinfo() page in the browser (the CLI and Apache PHP may have different configs). 4) If running PHP-FPM: restart php-fpm as well as Apache.
localhost (Unix socket) and 127.0.0.1 (TCP) as different hosts. If you created the user as 'app'@'localhost' but your DSN uses host=127.0.0.1, MySQL sees 'app'@'127.0.0.1' — a different user that doesn't exist. Fix: keep host=localhost in the DSN (uses the socket, faster) and create the user as 'app'@'localhost'. Or change the DSN to 127.0.0.1 and create the user as 'app'@'127.0.0.1'. They must match.
host=localhost in PDO means "use the socket", but the socket path in php.ini may differ from MySQL's actual socket path. Check MySQL's socket: mysql -e "SHOW VARIABLES LIKE 'socket';" (typically /var/run/mysqld/mysqld.sock). Check PHP's configured path: php -i | grep pdo_mysql.default_socket. If they differ, either add unix_socket=/var/run/mysqld/mysqld.sock to your DSN, or set host=127.0.0.1 to switch to TCP.
display_errors is off (which is correct for production, but makes debugging hard). During development: check the Apache error log immediately — sudo tail -f /var/log/apache2/error.log. All PHP errors and uncaught exceptions appear there. Temporarily you can also set ini_set('display_errors', '1'); at the top of the script, but remove it before deploying.
php -r "new PDO(..., 'password')" during testing, that password is visible in ps aux while running and stored in shell history. For scripts that need the password: use the credentials file pattern, not environment variables set on the command line. Check shell history: history | grep -i pass — delete any lines containing passwords with history -d <line_number>, or clear the whole history with history -c.
Quick Reference — Chapter 8
| Pattern | The correct approach |
|---|---|
| Install extension | sudo apt install php-mysql && sudo systemctl restart apache2 |
| Verify drivers | php -m | grep -i mysql — expect mysqli, pdo_mysql |
| Credentials location | Above the web root: /var/www/config/db.php (not inside /var/www/html/) |
| Credentials file permissions | chown root:www-data + chmod 640 |
| DSN for local MySQL | mysql:host=localhost;dbname=mydb;charset=utf8mb4 |
| Required PDO options | ERRMODE_EXCEPTION + FETCH_ASSOC + EMULATE_PREPARES=false |
| User input in queries | Always via prepared statement placeholders — never string concatenation |
| Fetch one row | $stmt->fetch() — returns array or false |
| Fetch all rows | $stmt->fetchAll() — returns array of arrays (empty if none) |
| Last inserted ID | $pdo->lastInsertId() after INSERT |
| Rows affected | $stmt->rowCount() after UPDATE / DELETE |
| Error handling | catch PDOException → error_log($e->getMessage()) → throw generic RuntimeException |
| Transaction | beginTransaction() → execute steps → commit() or rollBack() in catch |
| Character set | Always utf8mb4 — never MySQL's utf8 |
| Password hashing | password_hash($pass, PASSWORD_DEFAULT) to store, password_verify() to check |