Appearance
File Upload & Storage Pattern
Flow ID: SY-25 Module(s): Advuploader, UploadService, HandlesUploadActions, Storage Complexity: Medium Last Updated: 2026-05-07
Business Context
File uploads are one of the most common admin operations in Ecommercen. Images and documents are attached to vendors, product categories, blog articles, events, maps, badges, sliders, promo banners, downloadable product files, video showcases, contact form submissions, and more. At least 22 admin controllers and 18 REST controllers use this pattern.
Two parallel upload mechanisms exist:
- Legacy layer (
Advuploader) -- used by traditional admin controllers that submit HTML forms withenctype="multipart/form-data". - Modern REST layer (
UploadService+HandlesUploadActions) -- used by Vue-powered admin panels that call REST endpoints with eithermultipart/form-dataorapplication/json.
Both layers share the same core pipeline: validate file type and size, generate a unique encrypted filename, write to storage via the Flysystem abstraction, and return the filename for DB persistence. The storage backend is configurable (local disk, S3, SFTP) via environment variables -- see SY-28 Storage Abstraction for driver details.
Architecture
LEGACY PATH REST PATH
---------- ---------
Admin HTML form POST Vue admin multipart POST
| |
v v
CI_Controller::edit() REST Controller (store/update)
| |
v v
MY_Input::getValidServerPostsFileKeys() HandlesUploadActions::parseInput()
| (reads $_POST + $_FILES for multipart,
v or JSON for data-only)
Advuploader |
uploadFile[s|SameFolder|UsingConfig]() v
| HandlesUploadActions::processFileUploads()
v |
AdvUploadValidator::validate() v
| UploadService::processUploads()
v |
UploadFileOptions (value object) v
| AdvUploadValidator::validate()
v |
AdvUploadFileNameGenerator::__invoke() v
(md5 encrypt + dedup check) UploadFileOptions + AdvUploadFileNameGenerator
| |
v v
Storage::put() Storage::put()
(Flysystem write to disk/S3) (Flysystem write to disk/S3)
| |
v v
Return filename Return filename
(controller stores in DB) (merged into input, saved by WriteService)Key Components
| Component | Path | Role |
|---|---|---|
Advuploader | application/libraries/Advuploader.php | Legacy upload library; orchestrates validate-generate-store for one or more files |
UploadService | src/Domains/Support/Upload/UploadService.php | Modern DI-managed upload service; mirrors Advuploader logic for REST layer |
UploadFieldConfig | src/Domains/Support/Upload/UploadFieldConfig.php | Value object defining folder, allowed types, and max size for one upload field |
UploadException | src/Domains/Support/Upload/UploadException.php | Extends ValidationException; thrown on upload validation or storage failure |
HandlesUploadActions | src/Rest/Support/Controllers/HandlesUploadActions.php | REST trait replacing HandlesWriteActions for entities with file fields |
AdvUploadValidator | ecommercen/libraries/AdvUploadValidator.php | Validates file size, type (extension-based), and image dimensions |
UploadValidator | application/libraries/UploadValidator.php | Client-overridable subclass of AdvUploadValidator |
UploadFileOptions | ecommercen/libraries/UploadFileOptions.php | Value object pairing a $_FILES entry with target path, storage type, and options |
AdvUploadFileNameGenerator | ecommercen/libraries/AdvUploadFileNameGenerator.php | Generates encrypted filenames (md5(uniqid(mt_rand()))) with dedup via storage()->exists() |
UploadFileNameGenerator | application/libraries/UploadFileNameGenerator.php | Client-overridable subclass of AdvUploadFileNameGenerator |
Storage | src/Storage/Storage.php | Flysystem wrapper supporting local, S3, and SFTP drivers |
storage() | application/helpers/storage_helper.php | Global helper returning singleton Storage instances |
MY_Input::getValidServerPostsFileKeys() | application/core/MY_Input.php | Filters $_FILES to only keys with non-empty name |
BaseResource::formatFile() | src/Rest/Support/Resources/BaseResource.php | Prepends the storage path prefix when serializing filenames to JSON |
| Upload DI container | src/Domains/Support/Upload/container.php | Registers UploadService with global config values |
Configuration
| Config Key | File | Value |
|---|---|---|
max_upload_size | application/config/app.php | 19000 (KB ≈ 19 MB) — global default max file size. All upload paths (AdvUploadValidator, CI3 Upload, UploadService) treat this value as kilobytes. |
imagesAllowedTypes | application/config/app.php | jpeg|jpg|png|gif|webp|JPEG|JPG|PNG|GIF|WEBP |
productDownloadTypes | application/config/app.php | Array of allowed extensions for product downloads (pdf only) |
documentsAllowedTypes | application/config/app.php | Array of allowed extensions for document uploads |
streamAllowedTypes | application/config/app.php | Allowed types for video file uploads |
FILES_STORAGE_DISK | .env | Storage driver for the files type (local or s3) |
FORMS_ATTACHMENTS_PATH | application/config/constants.php | ../storage/forms/ -- private path for contact form attachments |
Code Flow
1. Legacy Upload (Admin Controllers)
Most legacy admin controllers follow this pattern:
1. Load Advuploader library
2. Get valid file keys from $_FILES via MY_Input::getValidServerPostsFileKeys()
3. Call one of:
- uploadFile($key, $folder) -- single file
- uploadFilesSameFolder($keys, $folder) -- multiple files, same folder
- uploadFilesUsingConfig($config) -- multiple files, per-field config
4. Check hasError() -- if true, render upload error messages
5. Retrieve generated filenames via getFile($key)
6. Include filenames in $data array
7. Save to DB via model->add_record() or model->update_record()Example: Vendor add (ecommercen/eshop/controllers/Adv_vendors_admin.php):
php
$this->load->library('advuploader');
$filesPostKeys = $this->input->getValidServerPostsFileKeys();
if (count($filesPostKeys)) {
$this->advuploader->uploadFilesSameFolder($filesPostKeys, 'files/vendors/');
if ($this->advuploader->hasError()) {
$uploadErrors = $this->advuploader->errors();
}
}
$imageFile = $this->advuploader->getFile('vendor_image');
$data = ['logo' => $imageFile, ...];
$this->vendors_model->add_record($data, $dataMui);2. Legacy Upload with Per-Field Config
Some controllers need different allowed types or sizes per field (e.g., products with mixed image/document uploads, video showcase with video/image/preview):
php
$config = [
'video_file' => [
'upload' => ['allowed_types' => 'mp4|webm|ogv', 'size' => 200 * 1024 * 1024],
'folder' => 'files/videos/videofiles/',
],
'thumbnail_image' => [
'upload' => ['allowed_types' => 'jpeg|jpg|png|gif|webp', 'size' => 2 * 1024 * 1024],
'folder' => 'files/videos/',
],
];
$this->advuploader->uploadFilesUsingConfig($config);3. Modern REST Upload (HandlesUploadActions)
REST controllers with file fields use the HandlesUploadActions trait instead of HandlesWriteActions:
1. Client sends POST with Content-Type: multipart/form-data
2. parseInput() detects multipart and reads $_POST (not JSON input stream)
3. processFileUploads() iterates uploadFieldsConfig()
4. For each field present in $_FILES:
a. UploadService::processUploads() validates via AdvUploadValidator
b. Generates encrypted filename via UploadFileNameGenerator
c. Writes to storage via Storage::put()
d. Merges generated filename into input array
5. WriteService::create() or ::update() persists data including filenames
6. Resource transformer returns data with formatFile()-prepended pathsExample: Badge controller (src/Rest/Product/Controllers/Badge.php):
php
class Badge extends HandlesRestfulActions
{
use HandlesUploadActions;
protected function uploadFieldsConfig(): array
{
return [
'badge' => new UploadFieldConfig('files/badges/'),
];
}
// store(), update(), destroy() delegate to doStore(), doUpdate(), doDestroy()
}3a. Field Protection in doUpdate()
HandlesUploadActions::doUpdate() calls a protected applyFieldProtection() method before delegating to WriteService::update(). When the authenticated user does not hold AUTH_ROLE_ADVISABLE, the method silently strips the following keys from the input:
- From each translation sub-array:
slug,vendor_slug,vendorSlug,url,redirect_url,redirectUrl - From base input:
slug
src/Rest/Support/Controllers/HandlesUploadActions.php:204-228— docblock: "Mirrors legacy canAccess(urlFields) restriction."
The method is invoked at HandlesUploadActions.php:153 (inside doUpdate()). The identical method exists in HandlesWriteActions at src/Rest/Support/Controllers/HandlesWriteActions.php:120.
Note:
applyFieldProtection()is called indoUpdate()only — not indoStore(). See Known Issues for the security implication.
3b. doStore / doUpdate / doDestroy Error Handling
All three write methods enforce a consistent set of guards:
| Guard | Triggered at | HTTP response |
|---|---|---|
| Null or empty parsed input | doStore :106, doUpdate :147 | 400 Invalid input |
| Empty array after upload processing | doStore :113, doUpdate :155 | 400 No data provided |
| Entity not found | doUpdate :140, doDestroy :182 | 404 Entity not found |
UploadException or ValidationException | catch block | sendValidationErrors() |
| Any other exception | fallback catch | 500 |
| Successful destroy | doDestroy response | {"success": true, "message": "Entity deleted successfully"} |
All citations reference src/Rest/Support/Controllers/HandlesUploadActions.php. Resource context is propagated through resourceContext to allow resource transformers to contextualise their output correctly across all three operations.
4. Legacy Delete-Image Flag Pattern
On edit forms, a checkbox posts del_image (or del_image_{langAbbr} for MUI) to clear an existing image without uploading a replacement:
php
// Vendor edit
if ($this->input->post('del_image')) {
$data['logo'] = null; // sets DB column to NULL
}
if ($imageFile) {
$data['logo'] = $imageFile; // new upload overwrites delete flag
}The delete flag does not remove the physical file from storage in most legacy controllers. It only nulls the DB reference, leaving an orphan file on disk.
5. REST Delete -- Physical File Cleanup
The REST layer handles file deletion properly. On destroy($id), HandlesUploadActions::deleteEntityFiles() iterates all upload fields and calls UploadService::deleteFile():
php
protected function deleteEntityFiles(object $entity): void
{
foreach ($this->uploadFieldsConfig() as $uploadKey => $config) {
$dataKey = $this->mapUploadKeyToDataKey($uploadKey);
$filename = $entity->{$dataKey} ?? null;
if ($filename) {
$this->uploadService->deleteFile($config->folder, $filename);
}
}
}UploadService::deleteFile() checks storage()->exists() before storage()->delete(), logging errors rather than throwing.
6. Validation Pipeline
AdvUploadValidator::validate() checks in order:
- File presence --
$_FILES[$field]exists and is non-empty - Upload status --
is_uploaded_file()with PHP upload error handling (UPLOAD_ERR_*) - File size -- against
max_size(in KB, multiplied by 1024 for comparison) - File type -- extension extracted from filename, checked against pipe-delimited allowed types
- Image dimensions -- if the file is an image, checks max/min width and height (when configured)
7. Filename Generation
AdvUploadFileNameGenerator::__invoke():
- Extract file extension from original filename
- If
encryptis true (default): generatemd5(uniqid(mt_rand())) + extension - Check if filename already exists in storage via
storage()->exists() - If collision: append incrementing number (1..99) until a unique name is found
- If no unique name found after 99 attempts: return empty string (logged as
upload_bad_filename)
Storage Directory Map
All paths are relative to FCPATH (the public/ web root) for the default local driver:
| Directory | Used by | Content |
|---|---|---|
files/badges/ | Badges | Badge images |
files/blogs/ | Blog articles, categories, authors | Banner images, promo banners |
files/builder/ | Block builder | Builder block images |
files/categories/ | CMS pages | Page banner images |
files/documents/ | Documents | Per-language PDF/document files and images |
files/event_categories/ | Event categories | Category images |
files/events/ | Events | Event images |
files/gifts/ | Gift rules | Gift images (image, promo, vendor, product) |
files/lines/ | Product lines | Line images and front images |
files/maps/ | Store maps | Map location images |
files/offer_categories/ | Offer categories | Category images |
files/product_attributes/ | Attributes | Attribute images |
files/product_category/ | Product categories | Category and small images |
files/product_list/ | Product lists | List images and small banners |
files/product_tag/ | Product tags | Tag images |
files/product_tag_category/ | Tag categories | Tag category images |
files/products/ | Products | Product images, videos, downloadable files |
files/promo/ | Promotions | Promo images and small banners |
files/slides/ | Sliders/slides | Slider and slide images |
files/suppliers/ | Suppliers | Supplier logos |
files/uploads/ | TinyMCE, settings | Editor-uploaded images, store logos |
files/vendors/ | Vendors | Logos, banners (main, medium, small) |
files/videos/ | Video showcase | Thumbnails, previews |
files/videos/videofiles/ | Video showcase | Uploaded video files |
../storage/forms/ | Contact forms | Form attachment files (private, outside web root) |
../storage/import/ | Bulk import | ZIP files for image import (private) |
REST Controllers Using HandlesUploadActions
All 18 REST controllers that use file uploads:
| Controller | Namespace | Upload Fields | Folder |
|---|---|---|---|
Badge | Rest\Product | badge | files/badges/ |
BlogArticle | Rest\Cms | banner_image | files/blogs/ |
BlogAuthor | Rest\Cms | banner_image | files/blogs/ |
BlogCategory | Rest\Cms | banner_image, small_banner, promo_banner | files/blogs/ |
Category | Rest\Product | image, small_image | files/product_category/ |
Download | Rest\Product | file (pdf|doc|docx|xls|xlsx|zip|rar) | files/products/ |
Event | Rest\Event | image | files/events/ |
EventCategory | Rest\Event | image | files/event_categories/ |
Line | Rest\Product | line_image, line_front_image | files/lines/ |
Map | Rest\Map | image | files/maps/ |
OfferCategory | Rest\Cms | image, image_2 | files/offer_categories/ |
Promo | Rest\Product | promo_image, promo_small_banner | files/promo/ |
ProductList | Rest\Product | image, small_banner | files/product_list/ |
Supplier | Rest\Product | logo | files/suppliers/ |
Tag | Rest\Product | tag_image | files/product_tag/ |
TagCategory | Rest\Product | tag_category_image | files/product_tag_category/ |
Vendor | Rest\Product | logo, vendor_banner, vendor_medium_banner, vendor_small_banner | files/vendors/ |
Video | Rest\Cms | thumb | files/videos/ |
Legacy Admin Controllers Using Advuploader
28+ legacy controllers grouped by upload method:
uploadFile() -- single file
Adv_badges.php--badges_imagetofiles/badges/Adv_category_admin.php--banner_imagetofiles/categories/Adv_product_tag_categories_admin.php--imagetofiles/product_tag_category/Adv_product_tags_admin.php--imagetofiles/product_tag/Adv_attributes_admin.php--imagetofiles/product_attributes/Adv_blog_author_admin.php--banner_imagetofiles/blogs/Adv_settings.php--logo_imagetofiles/uploads/Adv_saas.php--logotofiles/uploads/
uploadFilesSameFolder() -- multiple files, one folder
Adv_vendors_admin.php-- 4 images tofiles/vendors/Adv_lines_admin.php-- 2 images tofiles/lines/Adv_promo_admin.php-- 2 images tofiles/promo/Adv_blog_admin.php--banner_imagetofiles/blogs/Adv_blog_category_admin.php-- 3 images tofiles/blogs/Adv_product_categories_admin.php-- 2 images tofiles/product_category/Adv_product_list_admin.php-- 2 images tofiles/product_list/Adv_suppliers_admin.php-- files tofiles/suppliers//files/vendors/Adv_sliders_admin.php/Adv_slide_admin.php-- images tofiles/slides/Adv_events_admin.php/Adv_event_categories_admin.php-- images tofiles/events//files/event_categories/Adv_maps_admin.php-- images tofiles/maps/Adv_gifts_admin.php-- 4 images tofiles/gifts/Adv_offer_categories_admin.php-- images tofiles/offer_categories/AdvBlockBuilderController.php-- images tofiles/builder/AdvApiProductMedia.php-- product images tofiles/products/
uploadFilesUsingConfig() -- per-field config
Adv_products_admin.php-- product downloads with per-meta-field typesAdv_documents_admin.php-- per-language documents with document-specific allowed typesAdvVideoShowcaseAdmin.php-- video files, thumbnails, and previews with different types/sizesAdv_forms.php-- contact form attachments with per-form-field config
HTTP Method Notes
The REST layer uses POST for both create and update operations (not PUT) when file uploads are involved. This is because PHP does not populate $_POST and $_FILES for PUT requests with multipart/form-data. The HandlesUploadActions::parseInput() method detects the content type and reads from $_POST for multipart requests or from php://input for JSON.
Route definition pattern in rest_routes.php:
php
// POST /rest/product/vendor -> store (create)
// POST /rest/product/vendor/123 -> update (ID in URL)
// DELETE /rest/product/vendor/123 -> destroyResource Serialization
When REST resources return file fields, they always use BaseResource::formatFile() to prepend the storage path:
php
protected function formatFile(?string $file, string $path): ?string
{
return $file ? $path . $file : null;
}This means a DB value of a1b2c3d4e5.jpg becomes /files/vendors/a1b2c3d4e5.jpg in the API response. The path uses a leading slash to be relative to the web root. For S3-backed deployments, the CDN host is configured via APP_CDN_HOST and applied at the Flysystem adapter level.
DI Container Registration
UploadService is registered in src/Domains/Support/Upload/container.php:
php
$services->set(UploadService::class)
->arg('$maxUploadSize', $ci->config->item('max_upload_size'))
->arg('$defaultAllowedTypes', $ci->config->item('imagesAllowedTypes'))
->public();Each REST controller that uses HandlesUploadActions receives UploadService via constructor injection, wired in the module's container.php:
php
->arg('$uploadService', new Reference(\Advisable\Domains\Support\Upload\UploadService::class));Advuploader API Reference
| Method | Signature | Description |
|---|---|---|
uploadFile | ($postFileKey, $uploadFolder, $allowedTypes?, $uploadSize?) | Upload a single file from $_FILES[$postFileKey] |
uploadFiles | ($postFileKeysArray, $allowedTypes?, $uploadSize?) | Upload multiple files, keys map to folders: ['key' => 'folder/'] |
uploadFilesSameFolder | ($postFileKeysArray, $uploadFolder, $allowedTypes?, $uploadSize?) | Upload multiple files to the same folder |
uploadFilesUsingConfig | ($postFileKeysConfig) | Upload with per-field config arrays (folder + upload options) |
hasError | (): bool | Whether any upload in the batch failed |
errors | (): array | Error messages keyed by $_FILES key |
getFile | (string $postFileKey): string | Get the generated filename for a successfully uploaded file |
getResults | (): array | Get all generated filenames keyed by $_FILES key |
setAllowedTypes | (string $types): void | Override default allowed types (pipe-delimited) |
Business Rules
| Rule | Description |
|---|---|
| Encrypted filenames | All uploaded files are renamed with md5(uniqid(mt_rand())) by default; original filenames are never preserved |
| Extension-based validation | File type is determined by extension (not MIME sniffing); both lowercase and uppercase variants are typically allowed |
| No temp-to-final move | Files are written directly to their final storage path; there is no intermediate temp directory (except for ZIP imports, see SY-18) |
| Orphan files on legacy delete | Legacy del_ flag pattern nulls the DB reference but does not delete the physical file from storage |
| REST auto-cleanup on delete | HandlesUploadActions::deleteEntityFiles() removes physical files when an entity is destroyed |
| No REST auto-cleanup on replace | When a new file replaces an existing one via update, the old file is not automatically deleted (both legacy and REST) |
| Private storage for forms | Contact form attachments are stored outside the web root at ../storage/forms/ |
| Upload is synchronous | All uploads are processed in the HTTP request lifecycle; there is no async/queue-based upload |
| Max dedup attempts | Filename generator tries up to 99 incremented suffixes before failing |
| Error isolation | In multi-file uploads, each file is validated and uploaded independently; one failure does not abort others in Advuploader (it collects errors per key) |
Client Extension Points
Override
UploadValidator: Theapplication/libraries/UploadValidator.phpclass extendsAdvUploadValidatorand is empty by default. Clients can add custom validation rules (e.g., stricter file type restrictions, virus scanning).Override
UploadFileNameGenerator: Theapplication/libraries/UploadFileNameGenerator.phpclass extendsAdvUploadFileNameGenerator. Clients can change naming strategy (e.g., preserve original names, use SHA256 instead of MD5).Custom upload field configs: REST controllers in
custom/Rest/can define their ownuploadFieldsConfig()with custom folders and allowed types.Alias UploadService in DI: Client repos can create
Custom\Domains\Support\Upload\UploadServiceand alias it in the container to intercept or extend upload behavior.Custom storage types: Add new entries to
storage.phptypesarray for domain-specific storage backends.
Known Issues & Security Gaps
applyFieldProtection()absent fromdoStore()— slug protection is update-only.applyFieldProtection()is invoked indoUpdate()(src/Rest/Support/Controllers/HandlesUploadActions.php:163) but is not called indoStore()(:111-144). The same asymmetry exists inHandlesWriteActions(:26-52). As a result, non-Advisable users can set arbitraryslug,vendor_slug,url, andredirect_urlvalues when creating an entity but cannot change them on update. The commit body describes the restriction as intentional ("on update"), but this conflicts with the stated goal of mirroringcanAccess('urlFields'), which applied to both add and edit in the legacy layer. Citation:src/Rest/Support/Controllers/HandlesUploadActions.php:101-134, :153;src/Domains/Support/Slug/GeneratesSlugs.php:33.DoubleRESOLVED — the pre-filter in$_FILESpresence check betweenprocessFileUploads()andprocessUploads().HandlesUploadActions::processFileUploads()was removed;UploadService::processUploads()is the single source of truth for which fields are actually present in$_FILES.AdvUploadValidator::isImage()always returns false — image dimension validation is permanently dead code. Atecommercen/libraries/AdvUploadValidator.php:70,isImage()is called with$file['tmp_name'], which is a filesystem path (e.g.,/tmp/phpXXXXXX). However,isImage()compares the argument against MIME type strings. Because a filesystem path never matches a MIME string,isImage()always returnsfalse, meaning thevalidateImageDimensions()call on line 71 is never reached. Any configuredmax_width,max_height,min_width, ormin_heightconstraints are silently ignored for all uploads. Citation:ecommercen/libraries/AdvUploadValidator.php:70.
Tests
tests/Unit/Upload/ contains 43 unit tests for the cross-cutting upload pattern:
AdvUploadFileNameGeneratorTest.php— 8 tests covering the original-filename path, overwrite short-circuit, increment-on-collision, increment-budget exhaustion, encrypt mode, the documentedfileExtToLowercollision-path quirk (pinned with_BUGsuffix —str_replaceis case-sensitive whilegetExtensionlowercased the extension), and missing-extension handling.AdvUploadValidatorTest.php— 18 tests including theisImage()filesystem-path bug from Known Issues #3 below (pinned with_BUGsuffix), MIME alias normalisation, setter clamping,set_allowed_typesparsing variants,initialize()quirks (underscore guard, setter routing), error rendering, and thevalidate()no-file paths.UploadServiceTest.php— 3 tests covering the early-skip paths (empty$_FILES, empty-name field entry, empty field-config map). The validation-and-upload happy path is covered indirectly by per-domain controller tests because it depends on realis_uploaded_file()semantics.HandlesUploadActionsTest.php— 14 tests for the trait's pure-logic methods (mapUploadKeyToDataKey,parseInputcontent-type branching,processFileUploadsmerge semantics,applyFieldProtectionslug/URL stripping with and withoutAUTH_ROLE_ADVISABLE, translation-row protection, edge cases).
Per-domain integration coverage of the full upload pipeline (validator → filename generator → storage → entity write) lives in the test suites of individual controllers and domains that exercise it; see tests/Unit/ for per-domain test classes.
Related Flows
- SY-28 Storage Abstraction -- underlying Flysystem layer, driver configuration, and storage migration jobs
- SY-18 Image Upload from ZIP -- bulk product image import from ZIP archives
- SY-17 Bulk Product Import -- creates product_media records referenced by SY-18
- AD-02 Product Management -- product media upload via AdvApiProductMedia
- AD-19 Vendor Management -- example of multi-image upload entity
- AD-25 Badges -- example of single-image upload entity
- CF-29 Contact Forms -- form attachment uploads to private storage