Upload Multiple Images in CakePHP 5 — Step-by-Step Real Project Example (Product Module)
Learn how to upload multiple images in CakePHP 5.x using a real post module example. Save image names in the database, store files in a folder, handle edit and delete, and follow best security practices.
In this tutorial, we’ll learn how to upload multiple images in CakePHP 5.x using a real-world example. We’ll take a Product Module where each product can have multiple images. These images will be uploaded to a folder, while only their file names will be stored in the database. This approach is clean, scalable, and commonly used in real applications.
Why You Need Multiple Image Uploads
In most web applications, you’ll need to attach multiple images to a record — for example:
- Blog posts or product listings with image galleries
- Real estate or property listings with multiple photos
- Portfolio or project modules
- E-commerce product details
Instead of saving images as blobs in the database (which increases DB size), it’s better to upload them to a folder and store only their names or paths in the database. This makes your application faster and easier to maintain.
Step 1: Database Structure
We’ll create two tables: products and product_images. Each product can have multiple images.
SQL Schema:
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
modified DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE product_images (
id INT AUTO_INCREMENT PRIMARY KEY,
product_id INT NOT NULL,
file_name VARCHAR(255) NOT NULL,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
);
This structure ensures a one-to-many relationship between posts and their images. So when a product is deleted, its images are also removed automatically from the database.
Step 2: Define Model Relationships
Create models using CakePHP Bake command
bin/cake bake model Products
bin/cake bake model ProductImages
or manually make sure that they have relationship established.
In src/Model/Table/ProductsTable.php
$this->hasMany('ProductImages', [
'foreignKey' => 'product_id',
'dependent' => true,
'cascadeCallbacks' => true
]);
In src/Model/Table/ProductImagesTable.php
$this->belongsTo('Products', [
'foreignKey' => 'product_id'
]);
Step 3: Create Controller
Here’s how we handle multiple file uploads in the ProductsController.php.
<?php
namespace App\Controller;
use App\Controller\AppController;
class ProductsController extends AppController
{
public function add() {}
public function edit($id = null) {}
public function deleteImage($id) {}
public function delete($id = null){}
public function index() {}
}
The method add() handles adding a new product and uploading multiple images. It first saves product data, then creates a folder for that product’s images, renames and stores each uploaded image safely. Finally, it saves the image filenames in the product_images table.
public function add()
{
$product = $this->Products->newEmptyEntity();
if ($this->request->is('post')) {
$data = $this->request->getData();
$images = $data['images'] ?? [];
$product = $this->Products->patchEntity($product, $data);
if ($this->Products->save($product)) {
$uploadPath = WWW_ROOT . 'img/products/' . $product->id . '/';
if (!file_exists($uploadPath)) {
mkdir($uploadPath, 0775, true);
}
foreach ($images as $file) {
if ($file->getError() === UPLOAD_ERR_OK) {
$ext = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
$newName = $product->id . '_' . uniqid() . '_' . time() . '.' . $ext;
$file->moveTo($uploadPath . $newName);
$image = $this->Products->ProductImages->newEmptyEntity();
$image->product_id = $product->id;
$image->file_name = $newName;
$this->Products->ProductImages->save($image);
}
}
$this->Flash->success('Product created successfully with images.');
return $this->redirect(['action' => 'index']);
}
$this->Flash->error('Unable to save product.');
}
$this->set(compact('product'));
}
Notes:
- Uses patchEntity() to merge form data into the new product.
- Creates a directory inside /webroot/img/products/{id}.
- Renames files uniquely with product_id + uniqid + timestamp.
- Stores image names in the product_images table.
- Redirects to index page after success.
The next edit($id = null) method, lets you edit existing products and manage their images. You can add new images or remove existing ones. It updates both database records and image files on the server.
public function edit($id = null)
{
$product = $this->Products->get($id, ['contain' => ['ProductImages']]);
if ($this->request->is(['post', 'put', 'patch'])) {
$data = $this->request->getData();
$images = $data['images'] ?? [];
$product = $this->Products->patchEntity($product, $data);
if ($this->Products->save($product)) {
$uploadPath = WWW_ROOT . 'img/products/' . $product->id . '/';
if (!file_exists($uploadPath)) {
mkdir($uploadPath, 0775, true);
}
foreach ($images as $file) {
if ($file->getError() === UPLOAD_ERR_OK) {
$ext = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
$newName = $product->id . '_' . uniqid() . '_' . time() . '.' . $ext;
$file->moveTo($uploadPath . $newName);
$image = $this->Products->ProductImages->newEmptyEntity();
$image->product_id = $product->id;
$image->file_name = $newName;
$this->Products->ProductImages->save($image);
}
}
$this->Flash->success('Product updated successfully.');
return $this->redirect(['action' => 'index']);
}
$this->Flash->error('Unable to update product.');
}
$this->set(compact('product'));
}
Notes:
- Processes new uploaded files and saves them with new names.
- Maintains existing images unless deleted.
- Fetches product along with its associated images.
- Handles folder creation and file saving logic.
- Updates product info in DB and redirects to listing.
The next method deleteImage($id) is used when you delete a single image while editing a product. It removes both the image file and its corresponding database record. This gives a smoother user experience during image management.
public function deleteImage($id)
{
$image = $this->Products->ProductImages->get($id);
$uploadPath = WWW_ROOT . 'img/products/' . $image->product_id . '/';
$filePath = $uploadPath . $image->file_name;
if (file_exists($filePath)) {
unlink($filePath);
}
if ($this->Products->ProductImages->delete($image)) {
$this->Flash->success('Image deleted successfully.');
} else {
$this->Flash->error('Unable to delete image.');
}
return $this->redirect($this->referer());
}
Notes:
- Finds image by ID and constructs its file path.
- Deletes image file from product’s folder.
- Deletes the database record for that image.
- Shows flash message for success/failure.
- Redirects back to the previous page.
The method delete($id), deletes a product and all its related images from both database and storage folder. It ensures there are no leftover files or orphan records after deletion.
public function delete($id = null)
{
$this->request->allowMethod(['post', 'delete']);
$product = $this->Products->get($id, ['contain' => ['ProductImages']]);
if (!empty($product->product_images)) {
$uploadPath = WWW_ROOT . 'img/products/' . $product->id . '/';
foreach ($product->product_images as $img) {
$filePath = $uploadPath . $img->file_name;
if (file_exists($filePath)) {
unlink($filePath);
}
$this->Products->ProductImages->delete($img);
}
if (is_dir($uploadPath)) {
@rmdir($uploadPath);
}
}
if ($this->Products->delete($product)) {
$this->Flash->success(__('Product and its images deleted successfully.'));
} else {
$this->Flash->error(__('Unable to delete product.'));
}
return $this->redirect(['action' => 'index']);
}
Notes:
- Deletes product images from /webroot/img/products/{id}/.
- Removes image records from product_images table.
- Finally deletes the main product entry.
- Displays success or error message based on result.
The last method index(), fetches all the products from the database (with their related images) and sends them to the view. It helps display a product listing page where users can view all saved products. You can also add links for edit and delete actions here.
public function index()
{
$products = $this->Products->find('all', [
'contain' => ['ProductImages'],
'order' => ['Products.id' => 'DESC']
]);
$this->set(compact('products'));
}
Notes:
- Retrieves all product records using the ORM.
- Includes related ProductImages for preview.
- Sends data to view for displaying in a styled table.
Below is the complete code of the controller with all methods included.
<?php
namespace App\Controller;
use App\Controller\AppController;
class ProductsController extends AppController
{
public function add()
{
$product = $this->Products->newEmptyEntity();
if ($this->request->is('post')) {
$data = $this->request->getData();
$images = $data['images'] ?? [];
$product = $this->Products->patchEntity($product, $data);
if ($this->Products->save($product)) {
$uploadPath = WWW_ROOT . 'img/products/' . $product->id . '/';
if (!file_exists($uploadPath)) {
mkdir($uploadPath, 0775, true);
}
foreach ($images as $file) {
if ($file->getError() === UPLOAD_ERR_OK) {
$ext = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
$newName = $product->id . '_' . uniqid() . '_' . time() . '.' . $ext;
$file->moveTo($uploadPath . $newName);
$image = $this->Products->ProductImages->newEmptyEntity();
$image->product_id = $product->id;
$image->file_name = $newName;
$this->Products->ProductImages->save($image);
}
}
$this->Flash->success('Product created successfully with images.');
return $this->redirect(['action' => 'index']);
}
$this->Flash->error('Unable to save product.');
}
$this->set(compact('product'));
}
public function edit($id = null)
{
$product = $this->Products->get($id, ['contain' => ['ProductImages']]);
if ($this->request->is(['post', 'put', 'patch'])) {
$data = $this->request->getData();
$images = $data['images'] ?? [];
$product = $this->Products->patchEntity($product, $data);
if ($this->Products->save($product)) {
$uploadPath = WWW_ROOT . 'img/products/' . $product->id . '/';
if (!file_exists($uploadPath)) {
mkdir($uploadPath, 0775, true);
}
foreach ($images as $file) {
if ($file->getError() === UPLOAD_ERR_OK) {
$ext = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
$newName = $product->id . '_' . uniqid() . '_' . time() . '.' . $ext;
$file->moveTo($uploadPath . $newName);
$image = $this->Products->ProductImages->newEmptyEntity();
$image->product_id = $product->id;
$image->file_name = $newName;
$this->Products->ProductImages->save($image);
}
}
$this->Flash->success('Product updated successfully.');
return $this->redirect(['action' => 'index']);
}
$this->Flash->error('Unable to update product.');
}
$this->set(compact('product'));
}
public function deleteImage($id)
{
$image = $this->Products->ProductImages->get($id);
$uploadPath = WWW_ROOT . 'img/products/' . $image->product_id . '/';
$filePath = $uploadPath . $image->file_name;
if (file_exists($filePath)) {
unlink($filePath);
}
if ($this->Products->ProductImages->delete($image)) {
$this->Flash->success('Image deleted successfully.');
} else {
$this->Flash->error('Unable to delete image.');
}
return $this->redirect($this->referer());
}
public function delete($id = null)
{
$this->request->allowMethod(['post', 'delete']);
$product = $this->Products->get($id, ['contain' => ['ProductImages']]);
if (!empty($product->product_images)) {
$uploadPath = WWW_ROOT . 'img/products/' . $product->id . '/';
foreach ($product->product_images as $img) {
$filePath = $uploadPath . $img->file_name;
if (file_exists($filePath)) {
unlink($filePath);
}
$this->Products->ProductImages->delete($img);
}
if (is_dir($uploadPath)) {
@rmdir($uploadPath);
}
}
if ($this->Products->delete($product)) {
$this->Flash->success(__('Product and its images deleted successfully.'));
} else {
$this->Flash->error(__('Unable to delete product.'));
}
return $this->redirect(['action' => 'index']);
}
public function index()
{
$products = $this->Products->find('all', [
'contain' => ['ProductImages'],
'order' => ['Products.id' => 'DESC']
]);
$this->set(compact('products'));
}
}
Step 4: Create Form Views
File:
templates/Products/add.php
<?= $this->Html->css(['https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css']); ?>
<div class="container mt-4">
<h2>Add New Product</h2>
<?= $this->Form->create($product, ['type' => 'file', 'enctype' => 'multipart/form-data']); ?>
<div class="mb-3">
<?= $this->Form->control('title', [
'label' => 'Product Title',
'class' => 'form-control',
'placeholder' => 'Enter Product title',
'required' => true
]); ?>
</div>
<div class="mb-3">
<label>Upload Images</label>
<input type="file" id="imageInput" name="images[]" multiple accept="image/*" class="form-control">
</div>
<div id="previewContainer" style="display:flex; flex-wrap:wrap; gap:10px; margin-top:10px;"></div>
<div class="mt-3">
<?= $this->Form->button(__('Save Product'), ['class' => 'btn btn-primary']); ?>
</div>
<?= $this->Form->end(); ?>
</div>
Step 5: Client-side Image Preview & Remove Before Submission
In order to provide the Uploaded images preview on the client side, we can add below code in the add.php file.
<script>
const imageInput = document.getElementById('imageInput');
const previewContainer = document.getElementById('previewContainer');
let selectedFiles = [];
imageInput.addEventListener('change', (event) => {
const files = Array.from(event.target.files);
selectedFiles.push(...files);
renderPreviews();
});
function renderPreviews() {
previewContainer.innerHTML = '';
selectedFiles.forEach((file, index) => {
const reader = new FileReader();
reader.onload = (e) => {
const previewBox = document.createElement('div');
previewBox.style.position = 'relative';
previewBox.style.border = '1px solid #ddd';
previewBox.style.padding = '5px';
previewBox.style.borderRadius = '8px';
previewBox.style.background = '#f9f9f9';
const img = document.createElement('img');
img.src = e.target.result;
img.style.width = '100px';
img.style.height = '100px';
img.style.objectFit = 'cover';
img.style.borderRadius = '5px';
const removeBtn = document.createElement('button');
removeBtn.textContent = '×';
removeBtn.style.position = 'absolute';
removeBtn.style.top = '0';
removeBtn.style.right = '0';
removeBtn.style.background = 'red';
removeBtn.style.color = '#fff';
removeBtn.style.border = 'none';
removeBtn.style.borderRadius = '50%';
removeBtn.style.width = '20px';
removeBtn.style.height = '20px';
removeBtn.style.cursor = 'pointer';
removeBtn.addEventListener('click', () => {
selectedFiles.splice(index, 1);
renderPreviews();
});
previewBox.appendChild(img);
previewBox.appendChild(removeBtn);
previewContainer.appendChild(previewBox);
};
reader.readAsDataURL(file);
});
const dataTransfer = new DataTransfer();
selectedFiles.forEach((f) => dataTransfer.items.add(f));
imageInput.files = dataTransfer.files;
}
</script>
Step 6: Edit Product Page (Show Existing Images)
Inside templates/Products/edit.php:
<div class="container mt-4">
<h2>Edit Product</h2>
<?= $this->Form->create($product, ['type' => 'file', 'enctype' => 'multipart/form-data']); ?>
<div class="mb-3">
<?= $this->Form->control('title', ['label' => 'Product Title', 'class' => 'form-control']); ?>
</div>
<div class="mb-3">
<label>Upload New Images</label>
<input type="file" id="imageInput" name="images[]" multiple accept="image/*" class="form-control">
</div>
<div id="previewContainer" style="display:flex; flex-wrap:wrap; gap:10px; margin-top:10px;"></div>
<?php if (!empty($product->product_images)): ?>
<h4 class="mt-4">Already Uploaded Images</h4>
<div style="display:flex; flex-wrap:wrap; gap:10px;">
<?php foreach ($product->product_images as $img): ?>
<div style="position:relative; border:1px solid #ddd; padding:5px; border-radius:8px; background:#f9f9f9;">
<img src="<?= $this->Url->image('products/' . $product->id . '/' . $img->file_name) ?>"
width="100" height="100"
style="object-fit:cover; border-radius:5px;">
<a href="<?= $this->Url->build(['action' => 'deleteImage', $img->id]) ?>"
onclick="return confirm('Delete this image?')"
style="position:absolute; top:0; right:0; background:red; color:white; border:none; border-radius:50%; width:20px; height:20px; text-align:center; line-height:18px; text-decoration:none;">
×
</a>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="mt-3">
<?= $this->Form->button(__('Update Product'), ['class' => 'btn btn-success']); ?>
</div>
<?= $this->Form->end(); ?>
</div>
Step 7: Deleting Images
In your edit view, show each uploaded image with a small delete button:
<a href="<?= $this->Url->build(['action' => 'deleteImage', $img->id]) ?>"
onclick="return confirm('Delete this image?')"
style="position:absolute; top:0; right:0; background:red; color:white; border:none; border-radius:50%; width:20px; height:20px; text-align:center; line-height:18px; text-decoration:none;">
×
</a>
On click of that delete button, it will call the delectImage() method of the Controller and remove the record from the database and also from the folder.
Step 8: Show Listing of existing Products with Images (Optional)
You can create the page for showing the existing products with their Images. We already included the index() method in our controller file. Below is the sample code for the listing file templates/Products/index.php:
<h2>Product List</h2>
<table border="1" cellpadding="5">
<tr>
<th>ID</th>
<th>Name</th>
<th>Images</th>
<th>Actions</th>
</tr>
<?php foreach ($products as $product): ?>
<tr>
<td><?= h($product->id) ?></td>
<td><?= h($product->title) ?></td>
<td>
<?php if (!empty($product->product_images)): ?>
<?php foreach ($product->product_images as $img): ?>
<img src="<?= $this->Url->build('/img/products/' . $product->id . '/' . h($img->file_name)) ?>"
width="60" height="60" style="object-fit:cover; border-radius:4px;">
<?php endforeach; ?>
<?php endif; ?>
</td>
<td>
<?= $this->Html->link('Edit', ['action' => 'edit', $product->id]) ?> |
<?= $this->Form->postLink(
'Delete',
['action' => 'delete', $product->id],
['confirm' => 'Are you sure you want to delete this product?', 'class' => 'btn btn-danger btn-sm']
) ?>
</td>
</tr>
<?php endforeach; ?>
</table>
<br>
<?= $this->Html->link('Add New Product', ['action' => 'add'], ['class' => 'button']) ?>
Step 8: Best Practices and Tips
- Store only file names in the database, not full paths.
- Use folder structure based on product ID for better organization.
- Always validate file extensions (
jpg, png, jpeg, gif) before saving. - Restrict file size to avoid large uploads.
- Validate file types and size on both client and server side.
- Store uploads in a non-public folder if needed for privacy.
- Generate random unique file names to avoid conflicts.
- Use
chmodcarefully for folder permissions (avoid 777 on production). - Delete physical files when deleting records to save storage.
- Use HTTPS to protect uploads and data during transmission.
Conclusion
You have now built a complete multiple image upload system in CakePHP 5 using relational models. This setup can be used for blogs, product galleries, user portfolios, or any media module.