Skip to content

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:

  1. Legacy layer (Advuploader) -- used by traditional admin controllers that submit HTML forms with enctype="multipart/form-data".
  2. Modern REST layer (UploadService + HandlesUploadActions) -- used by Vue-powered admin panels that call REST endpoints with either multipart/form-data or application/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

ComponentPathRole
Advuploaderapplication/libraries/Advuploader.phpLegacy upload library; orchestrates validate-generate-store for one or more files
UploadServicesrc/Domains/Support/Upload/UploadService.phpModern DI-managed upload service; mirrors Advuploader logic for REST layer
UploadFieldConfigsrc/Domains/Support/Upload/UploadFieldConfig.phpValue object defining folder, allowed types, and max size for one upload field
UploadExceptionsrc/Domains/Support/Upload/UploadException.phpExtends ValidationException; thrown on upload validation or storage failure
HandlesUploadActionssrc/Rest/Support/Controllers/HandlesUploadActions.phpREST trait replacing HandlesWriteActions for entities with file fields
AdvUploadValidatorecommercen/libraries/AdvUploadValidator.phpValidates file size, type (extension-based), and image dimensions
UploadValidatorapplication/libraries/UploadValidator.phpClient-overridable subclass of AdvUploadValidator
UploadFileOptionsecommercen/libraries/UploadFileOptions.phpValue object pairing a $_FILES entry with target path, storage type, and options
AdvUploadFileNameGeneratorecommercen/libraries/AdvUploadFileNameGenerator.phpGenerates encrypted filenames (md5(uniqid(mt_rand()))) with dedup via storage()->exists()
UploadFileNameGeneratorapplication/libraries/UploadFileNameGenerator.phpClient-overridable subclass of AdvUploadFileNameGenerator
Storagesrc/Storage/Storage.phpFlysystem wrapper supporting local, S3, and SFTP drivers
storage()application/helpers/storage_helper.phpGlobal helper returning singleton Storage instances
MY_Input::getValidServerPostsFileKeys()application/core/MY_Input.phpFilters $_FILES to only keys with non-empty name
BaseResource::formatFile()src/Rest/Support/Resources/BaseResource.phpPrepends the storage path prefix when serializing filenames to JSON
Upload DI containersrc/Domains/Support/Upload/container.phpRegisters UploadService with global config values

Configuration

Config KeyFileValue
max_upload_sizeapplication/config/app.php19000 (KB ≈ 19 MB) — global default max file size. All upload paths (AdvUploadValidator, CI3 Upload, UploadService) treat this value as kilobytes.
imagesAllowedTypesapplication/config/app.phpjpeg|jpg|png|gif|webp|JPEG|JPG|PNG|GIF|WEBP
productDownloadTypesapplication/config/app.phpArray of allowed extensions for product downloads (pdf only)
documentsAllowedTypesapplication/config/app.phpArray of allowed extensions for document uploads
streamAllowedTypesapplication/config/app.phpAllowed types for video file uploads
FILES_STORAGE_DISK.envStorage driver for the files type (local or s3)
FORMS_ATTACHMENTS_PATHapplication/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 paths

Example: 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 in doUpdate() only — not in doStore(). See Known Issues for the security implication.

3b. doStore / doUpdate / doDestroy Error Handling

All three write methods enforce a consistent set of guards:

GuardTriggered atHTTP response
Null or empty parsed inputdoStore :106, doUpdate :147400 Invalid input
Empty array after upload processingdoStore :113, doUpdate :155400 No data provided
Entity not founddoUpdate :140, doDestroy :182404 Entity not found
UploadException or ValidationExceptioncatch blocksendValidationErrors()
Any other exceptionfallback catch500
Successful destroydoDestroy 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:

  1. File presence -- $_FILES[$field] exists and is non-empty
  2. Upload status -- is_uploaded_file() with PHP upload error handling (UPLOAD_ERR_*)
  3. File size -- against max_size (in KB, multiplied by 1024 for comparison)
  4. File type -- extension extracted from filename, checked against pipe-delimited allowed types
  5. Image dimensions -- if the file is an image, checks max/min width and height (when configured)

7. Filename Generation

AdvUploadFileNameGenerator::__invoke():

  1. Extract file extension from original filename
  2. If encrypt is true (default): generate md5(uniqid(mt_rand())) + extension
  3. Check if filename already exists in storage via storage()->exists()
  4. If collision: append incrementing number (1..99) until a unique name is found
  5. 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:

DirectoryUsed byContent
files/badges/BadgesBadge images
files/blogs/Blog articles, categories, authorsBanner images, promo banners
files/builder/Block builderBuilder block images
files/categories/CMS pagesPage banner images
files/documents/DocumentsPer-language PDF/document files and images
files/event_categories/Event categoriesCategory images
files/events/EventsEvent images
files/gifts/Gift rulesGift images (image, promo, vendor, product)
files/lines/Product linesLine images and front images
files/maps/Store mapsMap location images
files/offer_categories/Offer categoriesCategory images
files/product_attributes/AttributesAttribute images
files/product_category/Product categoriesCategory and small images
files/product_list/Product listsList images and small banners
files/product_tag/Product tagsTag images
files/product_tag_category/Tag categoriesTag category images
files/products/ProductsProduct images, videos, downloadable files
files/promo/PromotionsPromo images and small banners
files/slides/Sliders/slidesSlider and slide images
files/suppliers/SuppliersSupplier logos
files/uploads/TinyMCE, settingsEditor-uploaded images, store logos
files/vendors/VendorsLogos, banners (main, medium, small)
files/videos/Video showcaseThumbnails, previews
files/videos/videofiles/Video showcaseUploaded video files
../storage/forms/Contact formsForm attachment files (private, outside web root)
../storage/import/Bulk importZIP files for image import (private)

REST Controllers Using HandlesUploadActions

All 18 REST controllers that use file uploads:

ControllerNamespaceUpload FieldsFolder
BadgeRest\Productbadgefiles/badges/
BlogArticleRest\Cmsbanner_imagefiles/blogs/
BlogAuthorRest\Cmsbanner_imagefiles/blogs/
BlogCategoryRest\Cmsbanner_image, small_banner, promo_bannerfiles/blogs/
CategoryRest\Productimage, small_imagefiles/product_category/
DownloadRest\Productfile (pdf|doc|docx|xls|xlsx|zip|rar)files/products/
EventRest\Eventimagefiles/events/
EventCategoryRest\Eventimagefiles/event_categories/
LineRest\Productline_image, line_front_imagefiles/lines/
MapRest\Mapimagefiles/maps/
OfferCategoryRest\Cmsimage, image_2files/offer_categories/
PromoRest\Productpromo_image, promo_small_bannerfiles/promo/
ProductListRest\Productimage, small_bannerfiles/product_list/
SupplierRest\Productlogofiles/suppliers/
TagRest\Producttag_imagefiles/product_tag/
TagCategoryRest\Producttag_category_imagefiles/product_tag_category/
VendorRest\Productlogo, vendor_banner, vendor_medium_banner, vendor_small_bannerfiles/vendors/
VideoRest\Cmsthumbfiles/videos/

Legacy Admin Controllers Using Advuploader

28+ legacy controllers grouped by upload method:

uploadFile() -- single file

  • Adv_badges.php -- badges_image to files/badges/
  • Adv_category_admin.php -- banner_image to files/categories/
  • Adv_product_tag_categories_admin.php -- image to files/product_tag_category/
  • Adv_product_tags_admin.php -- image to files/product_tag/
  • Adv_attributes_admin.php -- image to files/product_attributes/
  • Adv_blog_author_admin.php -- banner_image to files/blogs/
  • Adv_settings.php -- logo_image to files/uploads/
  • Adv_saas.php -- logo to files/uploads/

uploadFilesSameFolder() -- multiple files, one folder

  • Adv_vendors_admin.php -- 4 images to files/vendors/
  • Adv_lines_admin.php -- 2 images to files/lines/
  • Adv_promo_admin.php -- 2 images to files/promo/
  • Adv_blog_admin.php -- banner_image to files/blogs/
  • Adv_blog_category_admin.php -- 3 images to files/blogs/
  • Adv_product_categories_admin.php -- 2 images to files/product_category/
  • Adv_product_list_admin.php -- 2 images to files/product_list/
  • Adv_suppliers_admin.php -- files to files/suppliers/ / files/vendors/
  • Adv_sliders_admin.php / Adv_slide_admin.php -- images to files/slides/
  • Adv_events_admin.php / Adv_event_categories_admin.php -- images to files/events/ / files/event_categories/
  • Adv_maps_admin.php -- images to files/maps/
  • Adv_gifts_admin.php -- 4 images to files/gifts/
  • Adv_offer_categories_admin.php -- images to files/offer_categories/
  • AdvBlockBuilderController.php -- images to files/builder/
  • AdvApiProductMedia.php -- product images to files/products/

uploadFilesUsingConfig() -- per-field config

  • Adv_products_admin.php -- product downloads with per-meta-field types
  • Adv_documents_admin.php -- per-language documents with document-specific allowed types
  • AdvVideoShowcaseAdmin.php -- video files, thumbnails, and previews with different types/sizes
  • Adv_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 -> destroy

Resource 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

MethodSignatureDescription
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(): boolWhether any upload in the batch failed
errors(): arrayError messages keyed by $_FILES key
getFile(string $postFileKey): stringGet the generated filename for a successfully uploaded file
getResults(): arrayGet all generated filenames keyed by $_FILES key
setAllowedTypes(string $types): voidOverride default allowed types (pipe-delimited)

Business Rules

RuleDescription
Encrypted filenamesAll uploaded files are renamed with md5(uniqid(mt_rand())) by default; original filenames are never preserved
Extension-based validationFile type is determined by extension (not MIME sniffing); both lowercase and uppercase variants are typically allowed
No temp-to-final moveFiles 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 deleteLegacy del_ flag pattern nulls the DB reference but does not delete the physical file from storage
REST auto-cleanup on deleteHandlesUploadActions::deleteEntityFiles() removes physical files when an entity is destroyed
No REST auto-cleanup on replaceWhen a new file replaces an existing one via update, the old file is not automatically deleted (both legacy and REST)
Private storage for formsContact form attachments are stored outside the web root at ../storage/forms/
Upload is synchronousAll uploads are processed in the HTTP request lifecycle; there is no async/queue-based upload
Max dedup attemptsFilename generator tries up to 99 incremented suffixes before failing
Error isolationIn 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

  1. Override UploadValidator: The application/libraries/UploadValidator.php class extends AdvUploadValidator and is empty by default. Clients can add custom validation rules (e.g., stricter file type restrictions, virus scanning).

  2. Override UploadFileNameGenerator: The application/libraries/UploadFileNameGenerator.php class extends AdvUploadFileNameGenerator. Clients can change naming strategy (e.g., preserve original names, use SHA256 instead of MD5).

  3. Custom upload field configs: REST controllers in custom/Rest/ can define their own uploadFieldsConfig() with custom folders and allowed types.

  4. Alias UploadService in DI: Client repos can create Custom\Domains\Support\Upload\UploadService and alias it in the container to intercept or extend upload behavior.

  5. Custom storage types: Add new entries to storage.php types array for domain-specific storage backends.


Known Issues & Security Gaps

  1. applyFieldProtection() absent from doStore() — slug protection is update-only. applyFieldProtection() is invoked in doUpdate() (src/Rest/Support/Controllers/HandlesUploadActions.php:163) but is not called in doStore() (:111-144). The same asymmetry exists in HandlesWriteActions (:26-52). As a result, non-Advisable users can set arbitrary slug, vendor_slug, url, and redirect_url values 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 mirroring canAccess('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.

  2. Double $_FILES presence check between processFileUploads() and processUploads(). RESOLVED — the pre-filter in HandlesUploadActions::processFileUploads() was removed; UploadService::processUploads() is the single source of truth for which fields are actually present in $_FILES.

  3. AdvUploadValidator::isImage() always returns false — image dimension validation is permanently dead code. At ecommercen/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 returns false, meaning the validateImageDimensions() call on line 71 is never reached. Any configured max_width, max_height, min_width, or min_height constraints 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 documented fileExtToLower collision-path quirk (pinned with _BUG suffix — str_replace is case-sensitive while getExtension lowercased the extension), and missing-extension handling.
  • AdvUploadValidatorTest.php — 18 tests including the isImage() filesystem-path bug from Known Issues #3 below (pinned with _BUG suffix), MIME alias normalisation, setter clamping, set_allowed_types parsing variants, initialize() quirks (underscore guard, setter routing), error rendering, and the validate() 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 real is_uploaded_file() semantics.
  • HandlesUploadActionsTest.php — 14 tests for the trait's pure-logic methods (mapUploadKeyToDataKey, parseInput content-type branching, processFileUploads merge semantics, applyFieldProtection slug/URL stripping with and without AUTH_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.