Upgrade legacy PHP code to an object-oriented structure with PDO to enhance security, scalability, and maintainability in your applications.
You inherit a PHP app that works. It also has SQL queries built by gluing strings together, database calls scattered through every file, and one function that nobody wants to touch. Rewriting it all at once is a bad idea. But you can move it toward something you can actually maintain, one class at a time.
This is the practical path: take procedural PHP and reshape it into small classes that use PDO and prepared statements for the database work. You’ll cover how to design those classes, how to handle everyday CRUD, and the handful of habits that keep the result secure.
Table of Contents
- Why Migrate to Object-Oriented PHP?
- Overview of PDO in OOP
- Designing OOP Classes for Database Operations
- Implementing CRUD Operations with PDO
- Best Practices for OOP with PDO
- Conclusion
Why Migrate to Object-Oriented PHP?
The real payoff isn’t “OOP is modern.” It’s that procedural code tends to repeat itself and leak database logic everywhere, so a schema change means hunting through ten files. Grouping related behavior into a class gives you one place to change, one place to test, and clear boundaries between your data layer and everything else.
There’s a compatibility angle too. Most current PHP frameworks, libraries, and extensions expose object-oriented APIs, so code shaped this way slots in without fighting. And honestly, code you can read six months later is its own reward.
Overview of PDO in OOP
PDO (PHP Data Objects) is a database access layer that runs queries through prepared statements, which is what keeps user input out of your SQL. It speaks to multiple database drivers through one object-oriented API and gives you transactions and exception-based error handling out of the box. The old mysql_* functions it replaces were removed in PHP 7.0, so if your legacy code still uses them, it won’t even run on a supported PHP version.
The pattern in an OOP codebase is to funnel every database call through a dedicated class or service. You pass around one connection, keep the query logic in one spot, and the rest of your app never touches raw SQL.
Designing OOP Classes for Database Operations
Start with the things your app talks about. If it manages users, a User class is the obvious first move: reading a user, creating one, updating, deleting. Each of those becomes a method, and the messy query lives behind a name that says what it does.
Here’s a sample structure for a User class:
Example of the code
<?php
/**
* Class User
*
* Handles CRUD operations for user data.
*/
class User {
/**
* Database connection instance.
*
* @var PDO
*/
private $db;
/**
* User constructor.
*
* @param PDO $db Database connection instance.
*/
public function __construct( PDO $db ) {
$this->db = $db;
}
/**
* Retrieve user data by ID.
*
* @param int $id User ID.
* @return array|false User data as associative array, or false on failure.
*/
public function get_user_by_id( $id ) {
$stmt = $this->db->prepare( "SELECT * FROM users WHERE id = :id" );
$stmt->bindParam( ':id', $id, PDO::PARAM_INT );
$stmt->execute();
return $stmt->fetch( PDO::FETCH_ASSOC );
}
/**
* Create a new user.
*
* @param string $name User's name.
* @param string $email User's email.
* @return bool True on success, false on failure.
*/
public function create_user( $name, $email ) {
$stmt = $this->db->prepare( "INSERT INTO users (name, email) VALUES (:name, :email)" );
$stmt->bindParam( ':name', $name, PDO::PARAM_STR );
$stmt->bindParam( ':email', $email, PDO::PARAM_STR );
return $stmt->execute();
}
}Notice the constructor takes the PDO connection as a typed argument rather than building one inside the class. That’s the whole trick behind reuse and testing: the class asks for what it needs and never cares where the connection came from. Marking $db private keeps that connection off-limits to the outside.
Implementing CRUD Operations with PDO
CRUD (Create, Read, Update, Delete) is the same four moves in every one of these classes. Each method prepares a statement, binds the parameters, and runs it. Once you’ve written one, the rest follow the same shape.
1. Create Operation
Insert with a prepared statement and bind the values you were handed. The create_user() method does exactly that:
Example of the code
<?php
/**
* Create a new user.
*
* @param string $name User's name.
* @param string $email User's email.
* @return bool True on success, false on failure.
*/
public function create_user( $name, $email ) {
$stmt = $this->db->prepare( "INSERT INTO users (name, email) VALUES (:name, :email)" );
$stmt->bindParam( ':name', $name, PDO::PARAM_STR );
$stmt->bindParam( ':email', $email, PDO::PARAM_STR );
return $stmt->execute();
}2. Read Operation
Reading a single row by id follows the same pattern. Bind the id, execute, fetch:
Example of the code
<?php
/**
* Retrieve user data by ID.
*
* @param int $id User ID.
* @return array|false User data as associative array, or false on failure.
*/
public function get_user_by_id( $id ) {
$stmt = $this->db->prepare( "SELECT * FROM users WHERE id = :id" );
$stmt->bindParam( ':id', $id, PDO::PARAM_INT );
$stmt->execute();
return $stmt->fetch( PDO::FETCH_ASSOC );
}3. Update Operation
Updating just adds more bound parameters, one for each column you’re changing plus the id you’re matching on:
Example of the code
<?php
/**
* Update user data.
*
* @param int $id User ID.
* @param string $name Updated name.
* @param string $email Updated email.
* @return bool True on success, false on failure.
*/
public function update_user( $id, $name, $email ) {
$stmt = $this->db->prepare( "UPDATE users SET name = :name, email = :email WHERE id = :id" );
$stmt->bindParam( ':id', $id, PDO::PARAM_INT );
$stmt->bindParam( ':name', $name, PDO::PARAM_STR );
$stmt->bindParam( ':email', $email, PDO::PARAM_STR );
return $stmt->execute();
}4. Delete Operation
Delete is the smallest of the four. Take an id, bind it, execute:
Example of the code
<?php
/**
* Delete a user by ID.
*
* @param int $id User ID.
* @return bool True on success, false on failure.
*/
public function delete_user( $id ) {
$stmt = $this->db->prepare( "DELETE FROM users WHERE id = :id" );
$stmt->bindParam( ':id', $id, PDO::PARAM_INT );
return $stmt->execute();
}Best Practices for OOP with PDO
None of this is exotic. It’s a short list of habits that, kept consistently, keep the codebase secure and easy to change:
- Inject the connection: Pass the PDO object into your classes instead of constructing it inside them. Everything shares one connection, and your tests can hand over a fake one.
- Always use prepared statements: This is your main defense against SQL injection. Never drop user input straight into a query string.
- Let errors surface: Since PHP 8.0, PDO throws a PDOException on failure by default; on older versions you set PDO::ERRMODE_EXCEPTION yourself. Either way, wrap risky calls in try/catch so a failed query doesn’t fail silently.
- Keep database logic in one layer: Confine queries to dedicated classes like Database or User so the rest of the app stays free of SQL.
- Use transactions for multi-step writes: When several queries have to succeed together, wrap them in a transaction so a half-finished operation rolls back instead of corrupting your data.
Conclusion
You don’t have to rewrite the whole thing this week. Pick one entity, give it a class, route its database work through PDO, and delete the procedural version once the new one holds. Repeat. Each pass leaves you with less duplicated SQL and fewer places an attacker can reach.
The refactoring costs you some time up front. What you get back is code that’s easier to read, harder to break, and ready for the next PHP version. Inject your connections, prepare your statements, keep the database logic in its own corner, and future-you will thank present-you.


