CRUD Operations in PHP Applications
Learning Objectives
- Build complete CRUD functionality with PHP and MySQL
- Implement data validation and sanitization
- Create user interfaces for data management
- Handle file uploads and storage
- Implement search and filtering
- Add pagination to result sets
- Build relationships between entities
- Apply MVC patterns to PHP applications
Understanding CRUD Operations
CRUD (Create, Read, Update, Delete) operations form the foundation of data-driven applications. Let's build a complete system! 🚀
CRUD Best Practices
Complete CRUD Implementation
graph TB
USER[User Interface] --> CONTROLLER[Controller]
CONTROLLER --> MODEL[Model/Database]
MODEL --> DB[(MySQL)]
CONTROLLER --> VALIDATE[Validation]
VALIDATE --> SANITIZE[Sanitization]
SANITIZE --> MODEL
MODEL --> RESULT[Result]
RESULT --> VIEW[View/Template]
VIEW --> USER
style USER fill:#dcfce7
style CONTROLLER fill:#dbeafe
style MODEL fill:#fef3c7
style DB fill:#e9d5ff
Complete Product Management System
<?php
// ========================================
// DATABASE CONNECTION CLASS
// ========================================
class Database {
private static $instance = null;
private $pdo;
private function __construct() {
$config = [
'host' => 'localhost',
'dbname' => 'shop',
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4'
];
try {
$dsn = "mysql:host={$config['host']};dbname={$config['dbname']};charset={$config['charset']}";
$this->pdo = new PDO($dsn, $config['username'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
]);
} catch (PDOException $e) {
die("Connection failed: " . $e->getMessage());
}
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getConnection() {
return $this->pdo;
}
}
// ========================================
// PRODUCT MODEL CLASS
// ========================================
class Product {
private $db;
private $table = 'products';
public function __construct() {
$this->db = Database::getInstance()->getConnection();
}
/**
* CREATE - Add new product
*/
public function create($data) {
try {
// Validate required fields
$required = ['name', 'price', 'category_id'];
foreach ($required as $field) {
if (empty($data[$field])) {
throw new Exception("Field '$field' is required");
}
}
// Sanitize data
$data = $this->sanitizeData($data);
// Prepare SQL
$sql = "INSERT INTO {$this->table}
(name, description, price, category_id, stock_quantity, sku, created_at)
VALUES
(:name, :description, :price, :category_id, :stock_quantity, :sku, NOW())";
$stmt = $this->db->prepare($sql);
// Bind parameters
$stmt->bindParam(':name', $data['name']);
$stmt->bindParam(':description', $data['description']);
$stmt->bindParam(':price', $data['price']);
$stmt->bindParam(':category_id', $data['category_id']);
$stmt->bindParam(':stock_quantity', $data['stock_quantity']);
$stmt->bindParam(':sku', $data['sku']);
// Execute
if ($stmt->execute()) {
return [
'success' => true,
'id' => $this->db->lastInsertId(),
'message' => 'Product created successfully'
];
}
return ['success' => false, 'message' => 'Failed to create product'];
} catch (Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* READ - Get products with pagination and filtering
*/
public function read($params = []) {
try {
// Base query
$sql = "SELECT p.*, c.name as category_name
FROM {$this->table} p
LEFT JOIN categories c ON p.category_id = c.id
WHERE 1=1";
$bindings = [];
// Add search filter
if (!empty($params['search'])) {
$sql .= " AND (p.name LIKE :search OR p.description LIKE :search)";
$bindings[':search'] = '%' . $params['search'] . '%';
}
// Add category filter
if (!empty($params['category'])) {
$sql .= " AND p.category_id = :category";
$bindings[':category'] = $params['category'];
}
// Add price range filter
if (!empty($params['min_price'])) {
$sql .= " AND p.price >= :min_price";
$bindings[':min_price'] = $params['min_price'];
}
if (!empty($params['max_price'])) {
$sql .= " AND p.price <= :max_price";
$bindings[':max_price'] = $params['max_price'];
}
// Count total records
$countSql = "SELECT COUNT(*) as total FROM ({$sql}) as count_table";
$countStmt = $this->db->prepare($countSql);
$countStmt->execute($bindings);
$total = $countStmt->fetch()['total'];
// Add sorting
$sortColumn = $params['sort'] ?? 'name';
$sortOrder = $params['order'] ?? 'ASC';
$allowedColumns = ['name', 'price', 'stock_quantity', 'created_at'];
if (in_array($sortColumn, $allowedColumns)) {
$sql .= " ORDER BY p.{$sortColumn} {$sortOrder}";
}
// Add pagination
$page = max(1, $params['page'] ?? 1);
$perPage = $params['per_page'] ?? 10;
$offset = ($page - 1) * $perPage;
$sql .= " LIMIT :limit OFFSET :offset";
// Prepare and execute
$stmt = $this->db->prepare($sql);
foreach ($bindings as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return [
'success' => true,
'data' => $stmt->fetchAll(),
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'total_pages' => ceil($total / $perPage)
]
];
} catch (Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* READ - Get single product
*/
public function findById($id) {
try {
$sql = "SELECT p.*, c.name as category_name
FROM {$this->table} p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.id = :id";
$stmt = $this->db->prepare($sql);
$stmt->execute([':id' => $id]);
$product = $stmt->fetch();
if ($product) {
return ['success' => true, 'data' => $product];
}
return ['success' => false, 'message' => 'Product not found'];
} catch (Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* UPDATE - Update product
*/
public function update($id, $data) {
try {
// Check if product exists
$existing = $this->findById($id);
if (!$existing['success']) {
return ['success' => false, 'message' => 'Product not found'];
}
// Sanitize data
$data = $this->sanitizeData($data);
// Build update query dynamically
$fields = [];
$bindings = [':id' => $id];
foreach (['name', 'description', 'price', 'category_id', 'stock_quantity', 'sku'] as $field) {
if (isset($data[$field])) {
$fields[] = "{$field} = :{$field}";
$bindings[":{$field}"] = $data[$field];
}
}
if (empty($fields)) {
return ['success' => false, 'message' => 'No fields to update'];
}
// Add updated_at
$fields[] = "updated_at = NOW()";
$sql = "UPDATE {$this->table} SET " . implode(', ', $fields) . " WHERE id = :id";
$stmt = $this->db->prepare($sql);
if ($stmt->execute($bindings)) {
return [
'success' => true,
'message' => 'Product updated successfully',
'affected_rows' => $stmt->rowCount()
];
}
return ['success' => false, 'message' => 'Failed to update product'];
} catch (Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* DELETE - Delete product (with soft delete option)
*/
public function delete($id, $soft = true) {
try {
if ($soft) {
// Soft delete - mark as deleted
$sql = "UPDATE {$this->table}
SET deleted_at = NOW()
WHERE id = :id AND deleted_at IS NULL";
} else {
// Hard delete - remove from database
$sql = "DELETE FROM {$this->table} WHERE id = :id";
}
$stmt = $this->db->prepare($sql);
$stmt->execute([':id' => $id]);
if ($stmt->rowCount() > 0) {
return [
'success' => true,
'message' => 'Product deleted successfully'
];
}
return ['success' => false, 'message' => 'Product not found or already deleted'];
} catch (Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* Bulk operations
*/
public function bulkDelete($ids) {
try {
$placeholders = str_repeat('?,', count($ids) - 1) . '?';
$sql = "UPDATE {$this->table}
SET deleted_at = NOW()
WHERE id IN ($placeholders)";
$stmt = $this->db->prepare($sql);
$stmt->execute($ids);
return [
'success' => true,
'message' => $stmt->rowCount() . ' products deleted'
];
} catch (Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* Data sanitization
*/
private function sanitizeData($data) {
$sanitized = [];
foreach ($data as $key => $value) {
switch ($key) {
case 'name':
case 'description':
case 'sku':
$sanitized[$key] = htmlspecialchars(trim($value), ENT_QUOTES, 'UTF-8');
break;
case 'price':
$sanitized[$key] = filter_var($value, FILTER_VALIDATE_FLOAT);
break;
case 'category_id':
case 'stock_quantity':
$sanitized[$key] = filter_var($value, FILTER_VALIDATE_INT);
break;
default:
$sanitized[$key] = $value;
}
}
return $sanitized;
}
}
User Interface for CRUD Operations
HTML Forms and UI Components
<?php
// ========================================
// PRODUCT CONTROLLER
// ========================================
class ProductController {
private $model;
public function __construct() {
$this->model = new Product();
}
/**
* Display products list
*/
public function index() {
// Get parameters from request
$params = [
'search' => $_GET['search'] ?? '',
'category' => $_GET['category'] ?? '',
'page' => $_GET['page'] ?? 1,
'sort' => $_GET['sort'] ?? 'name',
'order' => $_GET['order'] ?? 'ASC'
];
$result = $this->model->read($params);
if ($result['success']) {
$products = $result['data'];
$pagination = $result['pagination'];
include 'views/products/index.php';
} else {
$error = $result['message'];
include 'views/error.php';
}
}
/**
* Show create form
*/
public function create() {
include 'views/products/create.php';
}
/**
* Store new product
*/
public function store() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /products');
exit;
}
// CSRF token validation
if (!$this->validateCSRFToken($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = 'Invalid request';
header('Location: /products/create');
exit;
}
$result = $this->model->create($_POST);
if ($result['success']) {
$_SESSION['success'] = $result['message'];
header('Location: /products');
} else {
$_SESSION['error'] = $result['message'];
$_SESSION['old_input'] = $_POST;
header('Location: /products/create');
}
exit;
}
/**
* Show edit form
*/
public function edit($id) {
$result = $this->model->findById($id);
if ($result['success']) {
$product = $result['data'];
include 'views/products/edit.php';
} else {
$_SESSION['error'] = 'Product not found';
header('Location: /products');
exit;
}
}
/**
* Update product
*/
public function update($id) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /products');
exit;
}
// CSRF validation
if (!$this->validateCSRFToken($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = 'Invalid request';
header("Location: /products/{$id}/edit");
exit;
}
$result = $this->model->update($id, $_POST);
if ($result['success']) {
$_SESSION['success'] = $result['message'];
header('Location: /products');
} else {
$_SESSION['error'] = $result['message'];
$_SESSION['old_input'] = $_POST;
header("Location: /products/{$id}/edit");
}
exit;
}
/**
* Delete product
*/
public function destroy($id) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /products');
exit;
}
// CSRF validation
if (!$this->validateCSRFToken($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = 'Invalid request';
header('Location: /products');
exit;
}
$result = $this->model->delete($id);
if ($result['success']) {
$_SESSION['success'] = $result['message'];
} else {
$_SESSION['error'] = $result['message'];
}
header('Location: /products');
exit;
}
/**
* Generate CSRF token
*/
public function generateCSRFToken() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* Validate CSRF token
*/
private function validateCSRFToken($token) {
return isset($_SESSION['csrf_token']) &&
hash_equals($_SESSION['csrf_token'], $token);
}
}
// ========================================
// VIEW: products/index.php
// ========================================
?>
<!DOCTYPE html>
<html>
<head>
<title>Product Management</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>Products</h1>
<!-- Success/Error Messages -->
<?php if (isset($_SESSION['success'])): ?>
<div class="alert alert-success alert-dismissible fade show">
<?= htmlspecialchars($_SESSION['success']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php unset($_SESSION['success']); ?>
<?php endif; ?>
<!-- Search and Filter Form -->
<form method="GET" action="/products" class="row g-3 mb-4">
<div class="col-md-4">
<input type="text" name="search" class="form-control"
placeholder="Search products..."
value="<?= htmlspecialchars($params['search']) ?>">
</div>
<div class="col-md-3">
<select name="category" class="form-control">
<option value="">All Categories</option>
<!-- Categories would be loaded dynamically -->
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary">Search</button>
</div>
<div class="col-md-3 text-end">
<a href="/products/create" class="btn btn-success">Add New Product</a>
</div>
</form>
<!-- Products Table -->
<table class="table table-striped">
<thead>
<tr>
<th><a href="?sort=idℴ=<?= $params['order'] === 'ASC' ? 'DESC' : 'ASC' ?>">ID</a></th>
<th><a href="?sort=nameℴ=<?= $params['order'] === 'ASC' ? 'DESC' : 'ASC' ?>">Name</a></th>
<th>Category</th>
<th><a href="?sort=priceℴ=<?= $params['order'] === 'ASC' ? 'DESC' : 'ASC' ?>">Price</a></th>
<th>Stock</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($products as $product): ?>
<tr>
<td><?= $product['id'] ?></td>
<td><?= htmlspecialchars($product['name']) ?></td>
<td><?= htmlspecialchars($product['category_name']) ?></td>
<td>$<?= number_format($product['price'], 2) ?></td>
<td>
<span class="badge <?= $product['stock_quantity'] > 10 ? 'bg-success' : 'bg-warning' ?>">
<?= $product['stock_quantity'] ?>
</span>
</td>
<td>
<a href="/products/<?= $product['id'] ?>" class="btn btn-sm btn-info">View</a>
<a href="/products/<?= $product['id'] ?>/edit" class="btn btn-sm btn-warning">Edit</a>
<form method="POST" action="/products/<?= $product['id'] ?>/delete"
style="display:inline;" onsubmit="return confirm('Are you sure?')">
<input type="hidden" name="csrf_token" value="<?= $controller->generateCSRFToken() ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<!-- Pagination -->
<?php if ($pagination['total_pages'] > 1): ?>
<nav>
<ul class="pagination">
<?php for ($i = 1; $i <= $pagination['total_pages']; $i++): ?>
<li class="page-item <?= $i === $pagination['current_page'] ? 'active' : '' ?>">
<a class="page-link" href="?page=<?= $i ?>&search=<?= urlencode($params['search']) ?>">
<?= $i ?>
</a>
</li>
<?php endfor; ?>
</ul>
</nav>
<?php endif; ?>
</div>
</body>
</html>
Practice Exercise
CRUD Implementation Challenges