PHP 中如何优雅地实现 CRUD:从零到生产级的最佳实践
目录导读
- CRUD 的本质与 PHP 的定位
- 环境准备与数据库连接(PDO 篇)
- CREATE(创建)——安全插入数据的三种姿势
- READ(读取)——查询的艺术与防注入
- UPDATE(更新)——动态字段更新的陷阱
- DELETE(删除)——软删除与硬删除的抉择
- CRUD 封装成类——告别重复代码
- 常见问答(FAQ)
CRUD 的本质与 PHP 的定位
CRUD(Create, Read, Update, Delete)是绝大多数 Web 应用的基石,无论是博客、电商还是企业管理后台,底层都是对数据库进行的这四种基本操作,PHP 作为服务端脚本语言,天然适合处理 HTTP 请求并与 MySQL/PostgreSQL 等数据库交互。

关键点:现代 PHP(7.4+ / 8.x)已经拥有非常成熟的 PDO(PHP Data Objects)扩展,它提供了统一的数据库访问接口,并内置预处理语句(Prepared Statements),这是防 SQL 注入的第一道防线。任何不经过预处理拼接 SQL 的 CRUD 都是不负责任的。
环境准备与数据库连接(PDO 篇)
确保你的 php.ini 中启用了 pdo_mysql 扩展,使用 PDO 连接数据库的标准姿势:
<?php
$host = '127.0.0.1';
$db = 'test_db';
$user = 'root';
$pass = 'secret';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 异常模式
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 关联数组
PDO::ATTR_EMULATE_PREPARES => false, // 禁用模拟预处理,使用原生
];
try {
$pdo = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
// 生产环境记录错误日志,不要直接输出 $e->getMessage()
error_log($e->getMessage());
exit('数据库连接失败');
}
?>
注意:
utf8mb4比utf8支持更多字符(如 emoji),强烈推荐。
CREATE(创建)——安全插入数据的三种姿势
1 使用命名占位符(推荐)
$sql = "INSERT INTO users (name, email, age) VALUES (:name, :email, :age)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':name' => $username,
':email' => $email,
':age' => $age,
]);
$lastId = $pdo->lastInsertId(); // 获取自增ID
2 使用问号占位符
$sql = "INSERT INTO users (name, email) VALUES (?, ?)"; $stmt = $pdo->prepare($sql); $stmt->execute([$username, $email]);
3 批量插入的高效方式
$pdo->beginTransaction();
try {
$sql = "INSERT INTO logs (message) VALUES (?)";
$stmt = $pdo->prepare($sql);
foreach ($logMessages as $msg) {
$stmt->execute([$msg]);
}
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
}
注意:execute() 中的参数数组值会被自动转为字符串,如果字段是整数且比较敏感,可以显式 bindValue 并指定类型。
READ(读取)——查询的艺术与防注入
1 单行查询
$sql = "SELECT * FROM users WHERE email = :email LIMIT 1"; $stmt = $pdo->prepare($sql); $stmt->execute([':email' => $inputEmail]); $user = $stmt->fetch(); // 返回 false 表示未找到
2 多行查询与分页
$page = max(1, (int)$_GET['page'] ?? 1);
$perPage = 10;
$offset = ($page - 1) * $perPage;
$sql = "SELECT * FROM articles ORDER BY created_at DESC LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$articles = $stmt->fetchAll();
3 LIKE 查询的陷阱
$search = "%{$keyword}%";
$sql = "SELECT * FROM products WHERE name LIKE :kw";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':kw', $search);
$stmt->execute();
不要将 直接拼在 SQL 字符串里,而是作为参数值传入。
UPDATE(更新)——动态字段更新的陷阱
最常见的问题是:用户可能只修改其中一个字段,如何动态构建 SET 子句?
1 动态字段更新(安全写法)
$fields = [];
$params = [];
if (!empty($data['name'])) {
$fields[] = "name = :name";
$params[':name'] = $data['name'];
}
if (isset($data['age'])) {
$fields[] = "age = :age";
$params[':age'] = (int)$data['age'];
}
if (empty($fields)) {
return false; // 没有要更新的字段
}
$params[':id'] = $id;
$sql = "UPDATE users SET " . implode(', ', $fields) . " WHERE id = :id";
$stmt = $pdo->prepare($sql);
return $stmt->execute($params);
2 影响行数的判断
$rowCount = $stmt->rowCount(); // 注意:如果数据未发生变化,rowCount() 可能返回 0,这不一定是错误
DELETE(删除)——软删除与硬删除的抉择
1 硬删除(物理删除)
$sql = "DELETE FROM users WHERE id = :id"; $stmt = $pdo->prepare($sql); $stmt->execute([':id' => $id]);
2 软删除(推荐生产环境)
在表中增加 deleted_at TIMESTAMP NULL DEFAULT NULL 字段。
$sql = "UPDATE users SET deleted_at = NOW() WHERE id = :id"; $stmt = $pdo->prepare($sql); $stmt->execute([':id' => $id]); // 查询时默认过滤软删除数据 $sql = "SELECT * FROM users WHERE deleted_at IS NULL";
好处:可恢复、保留审计轨迹、避免外键约束断裂。代价:每次查询需额外条件。
CRUD 封装成类——告别重复代码
面向对象封装是生产级代码的核心,一个简单的 BaseModel 示例:
abstract class BaseModel {
protected PDO $pdo;
protected string $table;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function create(array $data): int {
$keys = array_keys($data);
$fields = implode(', ', $keys);
$placeholders = ':' . implode(', :', $keys);
$sql = "INSERT INTO {$this->table} ($fields) VALUES ($placeholders)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($data);
return (int)$this->pdo->lastInsertId();
}
public function find(int $id): ?array {
$sql = "SELECT * FROM {$this->table} WHERE id = :id";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':id' => $id]);
$result = $stmt->fetch();
return $result ?: null;
}
public function update(int $id, array $data): bool {
$fields = [];
foreach ($data as $key => $value) {
$fields[] = "$key = :$key";
}
$sql = "UPDATE {$this->table} SET ".implode(', ', $fields)." WHERE id = :id";
$data['id'] = $id;
$stmt = $this->pdo->prepare($sql);
return $stmt->execute($data);
}
public function delete(int $id): bool {
$sql = "DELETE FROM {$this->table} WHERE id = :id";
$stmt = $this->pdo->prepare($sql);
return $stmt->execute([':id' => $id]);
}
}
进阶:可以引入 Repository 模式或 Query Builder(如 Laravel 的 Eloquent),但在小型项目中,上述基类已经足够清晰复用。
常见问答(FAQ)
Q1: 为什么不能用 mysqli 或 mysql 扩展?
A: mysql 扩展已在 PHP 7.0 移除;mysqli 虽能用,但预处理语法繁琐,且面向过程风格易出错,PDO 支持多种数据库(MySQL、PostgreSQL、SQLite),且 API 统一,是主流框架标配。
Q2: 预处理语句真的能完全防注入吗?
A: 是的,只要值都通过参数绑定,且表名、字段名不能通过参数绑定,必须走白名单验证。ORDER BY 字段,建议使用数组映射。
Q3: 为什么 rowCount() 在 UPDATE 时返回 0?
A: 因为如果更新后的值与原始值相同,MySQL 默认返回受影响行数为 0,这是正常的,不能据此判断是否成功,可以改用 execute() 返回布尔值,或使用 SELECT ROW_COUNT() 更精确。
Q4: 数据库连接每次请求都新建,性能太差怎么办?
A: 使用长连接(PDO::ATTR_PERSISTENT => true),但需注意并发场景下的连接数控制,更推荐使用连接池(如 Swoole 或 PHP-FPM + ProxySQL)。
Q5: 如何处理 CRUD 中的事务性操作(如转账)?
A: 使用 beginTransaction()、commit()、rollBack(),务必在 try-catch 中确保异常时回滚,并设置 PDO::ATTR_ERRMODE 为异常模式。
PHP 写 CRUD 的核心不是语法,而是安全意识(预处理)、结构设计(封装复用)和性能考量(批量操作与索引),掌握 PDO 一劳永逸,配合现代 PHP 框架(Laravel/Symfony)的 ORM 可以更快,但底层原理必然相通,希望本文能帮你从"会写"进阶到"写好"。