diff --git a/Documentation-develop/CODING.md b/Documentation-develop/CODING.md
index b763de81b21d2997e2168676fa4360ec7f00aed4..690582a0710975b8eac57be3c695ff6a2ba5a2da 100644
--- a/Documentation-develop/CODING.md
+++ b/Documentation-develop/CODING.md
@@ -162,7 +162,7 @@ Upload to server, before 'save'
 ...............................
 * If a user open's a file for upload via the browse button, that file is immediately transmitted to the server. The user 
   will see a turning wheel until the upload finished.
-* After successfull upload the 'Browse' button disappears and the filename, plus the delete button, will be displayed (client logic). 
+* After successfully upload the 'Browse' button disappears and the filename, plus the delete button, will be displayed (client logic). 
 * The uploaded file will be checked: maxsize, mime type, check script.
 * The uploaded file is still temporary. It has been renamed from '[STORE_EXTRA][<uploadSip>][FILES_TMP_NAME]' to  
   '[STORE_EXTRA][<uploadSip>][FILES_TMP_NAME].cached'.
diff --git a/Documentation/Form.rst b/Documentation/Form.rst
index b9a47f44c72cf51f6b576497574adbe4de9a1d38..1c44bdfa14024ab658c8aabbeea596fd8539a736 100644
--- a/Documentation/Form.rst
+++ b/Documentation/Form.rst
@@ -586,7 +586,7 @@ The `mode` is given via (in this priority):
 Mode
 ;;;;
 
-* *standard*:
+* **standard**:
 
   * The form will behave like defined in the form editor.
   * Missing required values will a) be indicated and b) block saving the record.
@@ -2027,7 +2027,24 @@ FormElement.parameter
 
     * The following attributes are hard coded (can't be changed): `s|M:file|d|F`
 
-* fileSplit, fileDestinationSplit, tableNameSplit: see :ref:`split-pdf-upload`
+* *fileUnzip* - If the file is a ZIP file (only then) it will be unzipped. If no directory is given via ``fileUnzip``, the
+  basedir of ``fileDestination`` is taken, appended by ``unpack``.
+
+  If an unzip will be done, for each file of the archive STORE_VAR will be filled (name, path of the extracted file,
+  mime type, size) and the following will be triggered: *sqlValidate, slaveId, sqlBefore, sqlAfter, sqlInsert, sqlUpdate*.
+
+  Example::
+
+    fileDestination = fileadmin/file_{{id:R}}.zip
+    fileUnzip
+    sqlValidate ={{! SELECT '' FROM (SELECT '') AS fake WHERE '{{mimeType:V}}' LIKE 'application/pdf%' }}
+    expectRecords=1
+    messageFail=Unexpected filetype
+
+    # Set new
+    sqlAfter={{INSERT INTO Upload (pathFileName) VALUES '{{filename:V}}' }}
+
+* `fileSplit`, `fileDestinationSplit`, `tableNameSplit`: see :ref:`split-pdf-upload`
 
 * Excel Import: QFQ offers functionality to directly import excel data into the database. This functionality can
   optionally be combined with saving the file by using the above parameters like `fileDestination`.
@@ -2159,8 +2176,8 @@ file type.
     * [jpeg] - default: `-density 150 -quality 90`
 
   * *fileDestinationSplit* = `<pathFileName (pattern)>` - Target directory and filename pattern for the created &
-     split'ed files. Default <fileDestination>.split/split.<nr>.<fileSplit>.
-     If explicit given, respect that SVG needs a printf style for <nr>, whereas JPEG is numbered automatically. E.g. ::
+    split'ed files. Default <fileDestination>.split/split.<nr>.<fileSplit>.
+    If explicit given, respect that SVG needs a printf style for <nr>, whereas JPEG is numbered automatically. E.g. ::
 
        [svg] fileDestinationSplit = fileadmin/protected/{{id:R}}.{{filenameBase:V}}.%02d.svg
        [jpeg] fileDestinationSplit = fileadmin/protected/{{id:R}}.{{filenameBase:V}}.jpg
diff --git a/Documentation/Links.rst b/Documentation/Links.rst
deleted file mode 100644
index defbc6e87623d241641b6a1f7b8e6c5835bf4b6c..0000000000000000000000000000000000000000
--- a/Documentation/Links.rst
+++ /dev/null
@@ -1,58 +0,0 @@
-.. ==================================================
-.. ==================================================
-.. ==================================================
-.. Header hierarchy
-.. ==
-..  --
-..   ^^
-..    ""
-..     ;;
-..      ,,
-..
-.. --------------------------------------------used to the update the records specified ------
-.. Best Practice T3 reST: https://docs.typo3.org/m/typo3/docs-how-to-document/master/en-us/WritingReST/CheatSheet.html
-..             Reference: https://docs.typo3.org/m/typo3/docs-how-to-document/master/en-us/WritingReST/Index.html
-.. Italic *italic*
-.. Bold **bold**
-.. Code ``text``
-.. External Links: `Bootstrap <http://getbootstrap.com/>`_
-.. Add Images:    .. image:: ../Images/a4.jpg
-..
-..
-.. Admonitions
-..           .. note::   .. important::     .. tip::     .. warning::
-.. Color:   (blue)       (orange)           (green)      (red)
-..
-.. Definition:
-.. some text becomes strong (only one line)
-..      description has to indented
-
-.. -*- coding: utf-8 -*- with BOM.
-
-.. include:: Includes.txt
-
-
-.. _links:
-
-Links
------
-
-The links to issue and the GitHub repository are maintained in the Settings.cfg.
-
-You may want to remove this file if all important links are already handled in
-Settings.cfg.
-
-:Packagist:
-   https://packagist.org/packages/<username>/<extension key>
-
-:TER:
-   https://typo3.org/extensions/repository/view/<extension key>
-
-:Issues:
-   https://github.com/<username>/<extension key>/issues
-
-:GitHub Repository:
-   https://github.com/<username>/<extension key>
-
-:Contact:
-   `@<username> <https://twitter.com/your-username>`__
diff --git a/Documentation/index.rst b/Documentation/index.rst
index be57bdf8b7d3a14ce6fb050fd836b6e63a7207aa..643d134ea60556cf128da19cefd93d05fa672348 100644
--- a/Documentation/index.rst
+++ b/Documentation/index.rst
@@ -87,7 +87,6 @@ This documentation is for the TYPO3 extension **qfq**.
    ApplicationTest
    GeneralTips
    Release
-   Links
    License
    Sitemap
    SearchDocs
diff --git a/extension/Classes/Core/Constants.php b/extension/Classes/Core/Constants.php
index 3e03467c418cc6af3bd5f5178e8c56b0cce78cec..2f39901ea860341a78ac596416996e96d1f8efc0 100644
--- a/extension/Classes/Core/Constants.php
+++ b/extension/Classes/Core/Constants.php
@@ -242,7 +242,7 @@ const ERROR_STORE_KEY_EXIST = 1201;
 
 // I/O Error
 const ERROR_IO_COPY = 1300;
-
+const ERROR_IO_ZIP_OPEN = 1301;
 const ERROR_IO_RMDIR = 1302;
 const ERROR_IO_WRITE = 1303;
 const ERROR_IO_OPEN = 1304;
@@ -1129,6 +1129,8 @@ const FE_FILE_REPLACE_MODE = 'fileReplace'; // Flag if a) QFQ throw an error if
 const FE_FILE_REPLACE_MODE_ALWAYS = 'always'; // Value for flag FE_FILE_REPLACE_MODE
 const FE_FILE_MIME_TYPE_ACCEPT = 'accept'; // Comma separated list of mime types
 const FE_FILE_MAX_FILE_SIZE = SYSTEM_FILE_MAX_FILE_SIZE; // Max upload file size
+const FE_FILE_UNZIP = 'fileUnzip'; // 0|1|dir|{{SELECT ...}}
+const FE_FILE_UNPACK_DIR = 'unpack'; // default dir if not specified
 
 const FE_FILE_CAPTURE = 'capture'; // On a smartphone opens the camera
 const FE_FILE_SPLIT = 'fileSplit';
diff --git a/extension/Classes/Core/Form/FormAction.php b/extension/Classes/Core/Form/FormAction.php
index c452db09b1dd668f508d9893a9117e2650bfbd01..aed3b24c53451c714668b113f9f7ef3116139232 100644
--- a/extension/Classes/Core/Form/FormAction.php
+++ b/extension/Classes/Core/Form/FormAction.php
@@ -200,7 +200,7 @@ class FormAction {
                 $this->store->setStore($arr, STORE_LDAP, true);
             }
 
-            $this->sqlValidate($fe);
+            HelperFormElement::sqlValidate($this->evaluate, $fe);
 
             if ($fe[FE_TYPE] === FE_TYPE_SENDMAIL) {
                 $this->doSendMail($fe);
@@ -291,57 +291,6 @@ class FormAction {
         $sendMail->process($mailConfig);
     }
 
-    /**
-     * If there is a query defined in fe.parameter.FE_SQL_VALIDATE: fire them.
-     * Count the selected records and compare them with fe.parameter.FE_EXPECT_RECORDS.
-     * If match: everything is fine, do nothing.
-     * Else throw \UserFormException with error message of fe.parameter.FE_MESSAGE_FAIL
-     *
-     * @param array $fe
-     *
-     * @throws \CodeException
-     * @throws \DbException
-     * @throws \UserFormException
-     * @throws \UserReportException
-     */
-    private function sqlValidate(array $fe) {
-
-        // Is there something to check?
-        if ($fe[FE_SQL_VALIDATE] === '') {
-            return;
-        }
-
-        if ($fe[FE_EXPECT_RECORDS] === '') {
-            throw new \UserFormException("Missing parameter '" . FE_EXPECT_RECORDS . "'", ERROR_MISSING_EXPECT_RECORDS);
-        }
-        $expect = $this->evaluate->parse($fe[FE_EXPECT_RECORDS]);
-
-        if ($fe[FE_MESSAGE_FAIL] === '') {
-            throw new \UserFormException("Missing parameter '" . FE_MESSAGE_FAIL . "'", ERROR_MISSING_MESSAGE_FAIL);
-        }
-
-        // Do the check
-        $result = $this->evaluate->parse($fe[FE_SQL_VALIDATE], ROW_REGULAR);
-        if (!is_array($result)) {
-            throw new \UserFormException("Expected an array for '" . FE_SQL_VALIDATE . "', got a scalar. Please check for {{!...", ERROR_EXPECTED_ARRAY);
-        }
-
-        // If there is at least one record count given, who matches: return 'check succeeded'
-        $countRecordsArr = explode(',', $expect);
-        foreach ($countRecordsArr as $count) {
-            if (count($result) == $count) {
-                return; // check successfully passed
-            }
-        }
-
-        $msg = $this->evaluate->parse($fe[FE_MESSAGE_FAIL]); // Replace possible dynamic parts
-
-        // Throw user error message
-        throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => $msg
-            , ERROR_MESSAGE_TO_DEVELOPER => 'validate() failed']), ERROR_REPORT_FAILED_ACTION);
-
-    }
-
     /**
      * Process slaveId, sqlBefore, sqlInsert|sqlUpdate|sqlDelete, sqlAfter.
      * flagFeAction=false: for Native Elements
diff --git a/extension/Classes/Core/Helper/HelperFile.php b/extension/Classes/Core/Helper/HelperFile.php
index 5619f9c0c6cf2510d4c215ad5adcbd673690e00d..a9b1935276d6c9d7bcfc8286bd722dc84045514a 100644
--- a/extension/Classes/Core/Helper/HelperFile.php
+++ b/extension/Classes/Core/Helper/HelperFile.php
@@ -101,6 +101,8 @@ class HelperFile {
 
     /**
      * Returns an array with filestat information to $pathFileName
+     * - mimeType
+     * - fileSize
      *
      * @param $pathFileName
      * @return array
@@ -540,5 +542,43 @@ class HelperFile {
 
         return $pre . $separator . $post;
     }
+
+    /**
+     * Translates ZIP error codes to text.
+     *
+     * @param $errno
+     * @return string
+     */
+    public static function zipFileErrMsg($errno) {
+
+        // using constant name as a string to make this function PHP4 compatible
+        $zipFileFunctionsErrors = array(
+            'ZIPARCHIVE::ER_MULTIDISK' => 'Multi-disk zip archives not supported.',
+            'ZIPARCHIVE::ER_RENAME' => 'Renaming temporary file failed.',
+            'ZIPARCHIVE::ER_CLOSE' => 'Closing zip archive failed',
+            'ZIPARCHIVE::ER_SEEK' => 'Seek error',
+            'ZIPARCHIVE::ER_READ' => 'Read error',
+            'ZIPARCHIVE::ER_WRITE' => 'Write error',
+            'ZIPARCHIVE::ER_CRC' => 'CRC error',
+            'ZIPARCHIVE::ER_ZIPCLOSED' => 'Containing zip archive was closed',
+            'ZIPARCHIVE::ER_NOENT' => 'No such file.',
+            'ZIPARCHIVE::ER_EXISTS' => 'File already exists',
+            'ZIPARCHIVE::ER_OPEN' => 'Can\'t open file',
+            'ZIPARCHIVE::ER_TMPOPEN' => 'Failure to create temporary file.',
+            'ZIPARCHIVE::ER_ZLIB' => 'Zlib error',
+            'ZIPARCHIVE::ER_MEMORY' => 'Memory allocation failure',
+            'ZIPARCHIVE::ER_CHANGED' => 'Entry has been changed',
+            'ZIPARCHIVE::ER_COMPNOTSUPP' => 'Compression method not supported.',
+            'ZIPARCHIVE::ER_EOF' => 'Premature EOF',
+            'ZIPARCHIVE::ER_INVAL' => 'Invalid argument',
+            'ZIPARCHIVE::ER_NOZIP' => 'Not a zip archive',
+            'ZIPARCHIVE::ER_INTERNAL' => 'Internal error',
+            'ZIPARCHIVE::ER_INCONS' => 'Zip archive inconsistent',
+            'ZIPARCHIVE::ER_REMOVE' => 'Can\'t remove file',
+            'ZIPARCHIVE::ER_DELETED' => 'Entry has been deleted',
+        );
+
+        return $zipFileFunctionsErrors[$errno] ?? 'unknown';
+    }
 }
 
diff --git a/extension/Classes/Core/Helper/HelperFormElement.php b/extension/Classes/Core/Helper/HelperFormElement.php
index 33c8dcd1575de89af0d55dc7d3ee38d24737dd3d..b969ac401c692e350372f1b2328d0784d1a78a5b 100644
--- a/extension/Classes/Core/Helper/HelperFormElement.php
+++ b/extension/Classes/Core/Helper/HelperFormElement.php
@@ -8,6 +8,7 @@
 
 namespace IMATHUZH\Qfq\Core\Helper;
 
+use IMATHUZH\Qfq\Core\Evaluate;
 use IMATHUZH\Qfq\Core\Store\Store;
 
 
@@ -37,7 +38,7 @@ class HelperFormElement {
      */
     public static function explodeParameterInArrayElements(array &$elements, $keyName) {
 
-        foreach ($elements AS $key => $element) {
+        foreach ($elements as $key => $element) {
             self::explodeParameter($element, $keyName);
             $elements[$key] = $element;
         }
@@ -58,7 +59,7 @@ class HelperFormElement {
         // Do not add FE_SLAVE_ID - it's necessary to detect if a value is given or not.
         $default = [FE_SQL_BEFORE => '', FE_SQL_INSERT => '', FE_SQL_UPDATE => '', FE_SQL_DELETE => '', FE_SQL_AFTER => ''];
 
-        foreach ($elements AS $key => $element) {
+        foreach ($elements as $key => $element) {
             $elements[$key][FE_TG_INDEX] = 0;
             unset($elements[$key][FE_ADMIN_NOTE]);
 //            $elements[$key][FE_DATA_REFERENCE] = '';
@@ -91,7 +92,7 @@ class HelperFormElement {
             if (!$flagAllowOverwrite) {
                 // Check if some of the exploded keys conflict with existing keys
                 $checkKeys = array_keys($arr);
-                foreach ($checkKeys AS $checkKey) {
+                foreach ($checkKeys as $checkKey) {
                     if (!empty($element[$checkKey])) {
                         self::$store = Store::getInstance();
                         self::$store->setVar(SYSTEM_FORM_ELEMENT, Logger::formatFormElementName($element), STORE_SYSTEM);
@@ -861,5 +862,55 @@ EOF;
         return '<div class="help-block with-errors hidden"></div>';
     }
 
+    /**
+     * If there is a query defined in fe.parameter.FE_SQL_VALIDATE: fire them.
+     * Count the selected records and compare them with fe.parameter.FE_EXPECT_RECORDS.
+     * If match: everything is fine, do nothing.
+     * Else throw \UserFormException with error message of fe.parameter.FE_MESSAGE_FAIL
+     *
+     * @param array $fe
+     * @param Evaluate $evaluate
+     * @throws \CodeException
+     * @throws \DbException
+     * @throws \UserFormException
+     * @throws \UserReportException
+     */
+    public static function sqlValidate(Evaluate $evaluate, array $fe) {
+
+        // Is there something to check?
+        if ($fe[FE_SQL_VALIDATE] === '') {
+            return;
+        }
+
+        if ($fe[FE_EXPECT_RECORDS] === '') {
+            throw new \UserFormException("Missing parameter '" . FE_EXPECT_RECORDS . "'", ERROR_MISSING_EXPECT_RECORDS);
+        }
+        $expect = $evaluate->parse($fe[FE_EXPECT_RECORDS]);
 
+        if ($fe[FE_MESSAGE_FAIL] === '') {
+            throw new \UserFormException("Missing parameter '" . FE_MESSAGE_FAIL . "'", ERROR_MISSING_MESSAGE_FAIL);
+        }
+
+        // Do the check
+        $result = $evaluate->parse($fe[FE_SQL_VALIDATE], ROW_REGULAR);
+        if (!is_array($result)) {
+            throw new \UserFormException("Expected an array for '" . FE_SQL_VALIDATE . "', got a scalar. Please check for {{!...", ERROR_EXPECTED_ARRAY);
+        }
+
+        // If there is at least one record count given, who matches: return 'check succeeded'
+        $countRecordsArr = explode(',', $expect);
+        foreach ($countRecordsArr as $count) {
+            if (count($result) == $count) {
+                return; // check successfully passed
+            }
+        }
+
+        $msg = $evaluate->parse($fe[FE_MESSAGE_FAIL]); // Replace possible dynamic parts
+
+        // Throw user error message
+        throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => $msg
+                , ERROR_MESSAGE_TO_DEVELOPER => "validate() failed.\nSQL Raw: " . $fe[FE_SQL_VALIDATE]])
+            , ERROR_REPORT_FAILED_ACTION);
+
+    }
 }
\ No newline at end of file
diff --git a/extension/Classes/Core/Save.php b/extension/Classes/Core/Save.php
index 886444cae4b6274f6ee793ced7195c907f2799be..5e9cca3c8139ef44797e325ffb5213fd8bc40255 100644
--- a/extension/Classes/Core/Save.php
+++ b/extension/Classes/Core/Save.php
@@ -20,6 +20,7 @@ use IMATHUZH\Qfq\Core\Helper\Support;
 use IMATHUZH\Qfq\Core\Store\FillStoreForm;
 use IMATHUZH\Qfq\Core\Store\Sip;
 use IMATHUZH\Qfq\Core\Store\Store;
+use ZipArchive;
 
 /**
  * Class Save
@@ -254,7 +255,7 @@ class Save {
         $formValues = $this->createEmptyTemplateGroupElements($formValues);
 
         // Iterate over all table.columns. Built an assoc array $newValues.
-        foreach ($tableColumns AS $column) {
+        foreach ($tableColumns as $column) {
 
             // Never save a predefined 'id': autoincrement values will be given by database..
             if ($column === COLUMN_ID) {
@@ -408,7 +409,7 @@ class Save {
      */
     private function isColumnUploadField($feName) {
 
-        foreach ($this->feSpecNative AS $formElement) {
+        foreach ($this->feSpecNative as $formElement) {
             if ($formElement[FE_NAME] === $feName && $formElement[FE_TYPE] == FE_TYPE_UPLOAD)
                 return true;
         }
@@ -501,12 +502,22 @@ class Save {
 
         $sip = new Sip(false);
         $newValues = array();
-        $vars = array();
+
+        $flagDoUnzip = false;
 
         $formValues = $this->store->getStore(STORE_FORM);
         $primaryRecord = $this->store->getStore(STORE_RECORD); // necessary to check if the current formElement exist as a column of the primary table.
 
-        foreach ($this->feSpecNative AS $formElement) {
+        // Upload - Take care the necessary target directories exist.
+        $cwd = getcwd();
+        $sitePath = $this->store->getVar(SYSTEM_SITE_PATH, STORE_SYSTEM);
+        if ($cwd === false || $sitePath === false || !HelperFile::chdir($sitePath)) {
+            throw new \UserFormException(
+                json_encode([ERROR_MESSAGE_TO_USER => 'getcwd() failed or SITE_PATH undefined or chdir() failed', ERROR_MESSAGE_TO_DEVELOPER => "getcwd() failed or SITE_PATH undefined or chdir('$sitePath') failed."]),
+                ERROR_IO_CHDIR);
+        }
+
+        foreach ($this->feSpecNative as $formElement) {
             // skip non upload formElements
             if ($formElement[FE_TYPE] != FE_TYPE_UPLOAD) {
                 continue;
@@ -523,7 +534,34 @@ class Save {
             }
 
             $column = $formElement[FE_NAME];
+
+            $statusUpload = $this->store->getVar($formValues[$column] ?? '', STORE_EXTRA);
+            // Get file stats
+            $vars = array();
+            $vars[VAR_FILE_SIZE] = $statusUpload[FILES_SIZE] ?? '';
+            $vars[VAR_FILE_MIME_TYPE] = $statusUpload[FILES_TYPE] ?? '';
+
+            // Check for 'unzip'.
+            if (isset($formElement[FE_FILE_UNZIP])
+                && $formElement[FE_FILE_UNZIP] != '0'
+                && $vars[VAR_FILE_MIME_TYPE] == 'application/zip') {
+                $flagDoUnzip = true;
+            }
+
+            // Do upload
             $pathFileName = $this->doUpload($formElement, ($formValues[$column] ?? ''), $sip, $modeUpload);
+            if ($flagDoUnzip && $pathFileName != '') {
+                if ($formElement[FE_FILE_UNZIP] == '' || $formElement[FE_FILE_UNZIP] == '1') {
+                    // Set default dir.
+                    $formElement[FE_FILE_UNZIP] = HelperFile::joinPathFilename(dirname($pathFileName), FE_FILE_UNPACK_DIR);
+                }
+
+                // Backup STORE_VAR - will be changed in doUnzip()
+                $tmpStoreVar = $this->store->getStore(STORE_VAR);
+                $this->doUnzip($formElement, $pathFileName);
+                // Restore STORE_VAR
+                $this->store->setStore($tmpStoreVar, STORE_VAR, true);
+            }
 
             if ($modeUpload == UPLOAD_MODE_DELETEOLD && $pathFileName == '') {
                 $pathFileNameTmp = '';  // see '4'
@@ -540,15 +578,15 @@ class Save {
                 // No new upload and no existing: take care to remove previous upload file statistics.
                 $this->store->unsetVar(VAR_FILE_MIME_TYPE, STORE_VAR);
                 $this->store->unsetVar(VAR_FILE_SIZE, STORE_VAR);
-                $vars[VAR_FILE_SIZE] = 0;
-                $vars[VAR_FILE_MIME_TYPE] = '';
             } else {
-                $vars = HelperFile::getFileStat($pathFileNameTmp);
+
                 $this->store->appendToStore($vars, STORE_VAR);
             }
 
             // If given: fire a sqlBefore query
-            $this->evaluate->parse($formElement[FE_SQL_BEFORE]);
+            if (!$flagDoUnzip) {
+                $this->evaluate->parse($formElement[FE_SQL_BEFORE]);
+            }
 
             // Upload Type: Simple or Advanced
             // If (isset($primaryRecord[$column])) { - see #5048 - isset does not deal correctly with NULL!
@@ -567,22 +605,101 @@ class Save {
                 }
             } elseif (isset($formElement[FE_IMPORT_TO_TABLE]) && !isset($formElement[FE_SLAVE_ID])) {
                 // Excel import on nonexisting column -> no upload
+            } elseif ($flagDoUnzip) {
+                // If ZIP and advanced upload: process it not here but via doUnzip.
             } else {
                 // 'Advanced Upload'
                 $this->doUploadSlave($formElement, $modeUpload);
             }
 
             // If given: fire a sqlAfter query
-            $this->evaluate->parse($formElement[FE_SQL_AFTER]);
-
+            if (!$flagDoUnzip) {
+                $this->evaluate->parse($formElement[FE_SQL_AFTER]);
+            }
         }
 
+        // Clean up
+        HelperFile::chdir($cwd);
+
         // Only used in 'Simple Upload'
         if (count($newValues) > 0) {
             $this->updateRecord($this->formSpec[F_TABLE_NAME], $newValues, $recordId, $this->formSpec[F_PRIMARY_KEY]);
         }
     }
 
+    /**
+     * Unzip $pathFileName to $formElement[FE_FILE_UNZIP]. Before final extract, fire FE_SQL_VALIDATE.
+     * For each file in ZIP:
+     * - Fill STORE_VAR with VAR_FILENAME, VAR_FILENAME_ONLY, VAR_FILENAME_BASE, VAR_FILENAME_EXT, VAR_FILE_MIME_TYPE, VAR_FILE_SIZE.
+     * - Fire $formElement[FE_SQL_VALIDATE]
+     * - Fire FE_SLAVE_ID, FE_SQL_BEFORE, FE_SQL_INSERT, FE_SQL_UPDATE, FE_SQL_DELETE, FE_SQL_AFTER
+     *
+     * @param array $formElement
+     * @param string $pathFileName
+     * @throws \CodeException
+     * @throws \DbException
+     * @throws \UserFormException
+     * @throws \UserReportException
+     */
+    private function doUnzip(array $formElement, $pathFileName) {
+
+        if (!is_readable($pathFileName)) {
+            throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => "Open ZIP file failed",
+                ERROR_MESSAGE_TO_DEVELOPER => "File: " . $pathFileName]),
+                ERROR_IO_ZIP_OPEN);
+        }
+
+        $zip = new ZipArchive();
+        $res = $zip->open($pathFileName);
+        if ($res !== true) {
+            throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => "Open ZIP file failed" . HelperFile::zipFileErrMsg($res),
+                ERROR_MESSAGE_TO_DEVELOPER => "File: " . $pathFileName]), ERROR_IO_ZIP_OPEN);
+        }
+
+        // Extract
+        if (false === $zip->extractTo($formElement[FE_FILE_UNZIP])) {
+            throw new \UserFormException("Failed to extract ZIP.", ERROR_IO_ZIP_OPEN);
+        }
+
+        // Do sqlValidate() - to get mime type of zipped items, the archive has already been extracted.
+        if (!empty($formElement[FE_SQL_VALIDATE])) {
+            for ($i = 0; $i < $zip->numFiles; $i++) {
+                $stat = $zip->statIndex($i);
+
+                $itemPathFileName = HelperFile::joinPathFilename($formElement[FE_FILE_UNZIP], $stat['name']);
+                $this->store->appendToStore(HelperFile::getFileStat($itemPathFileName), STORE_VAR);
+                $this->store->appendToStore(HelperFile::pathinfo($itemPathFileName), STORE_VAR);
+
+                HelperFormElement::sqlValidate($this->evaluate, $formElement);
+            }
+        }
+
+        // Process
+        if (!isset($formElement[FE_SLAVE_ID])) {
+            $formElement[FE_SLAVE_ID] = '';
+        }
+
+        if (!empty($formElement[FE_SLAVE_ID] . $formElement[FE_SQL_BEFORE] . $formElement[FE_SQL_INSERT] .
+            $formElement[FE_SQL_UPDATE] . $formElement[FE_SQL_DELETE] . $formElement[FE_SQL_AFTER])) {
+            for ($i = 0; $i < $zip->numFiles; $i++) {
+                $stat = $zip->statIndex($i);
+
+                $itemPathFileName = HelperFile::joinPathFilename($formElement[FE_FILE_UNZIP], $stat['name']);
+                $this->store->appendToStore(HelperFile::getFileStat($itemPathFileName), STORE_VAR);
+                $this->store->appendToStore(HelperFile::pathinfo($itemPathFileName), STORE_VAR);
+
+                $this->evaluate->parse($formElement[FE_SQL_BEFORE]);
+                $this->doUploadSlave($formElement, UPLOAD_MODE_NEW);
+                $this->evaluate->parse($formElement[FE_SQL_AFTER]);
+            }
+        }
+
+        // Close Zip
+        if (false === $zip->close()) {
+            throw new \UserFormException("Failed to close ZIP.", ERROR_IO_ZIP_OPEN);
+        }
+    }
+
     /**
      * Process all Upload FormElements for the given $recordId.
      * After processing, &$formValues will be updated with the final filename.
@@ -592,7 +709,7 @@ class Save {
      */
     public function processAllImageCutFE() {
 
-        foreach ($this->feSpecNative AS $formElement) {
+        foreach ($this->feSpecNative as $formElement) {
             // skip non upload formElements
             if ($formElement[FE_TYPE] != FE_TYPE_IMAGE_CUT) {
                 continue;
@@ -631,7 +748,7 @@ class Save {
 
         $flagAllRequiredGiven = 1;
 
-        foreach ($this->feSpecNative AS $key => $formElement) {
+        foreach ($this->feSpecNative as $key => $formElement) {
 
             // Do not check retype slave FE.
             if (isset($formElement[FE_RETYPE_SOURCE_NAME])) {
@@ -748,26 +865,26 @@ class Save {
      * Process upload for the given Formelement. If necessary, delete a previous uploaded file.
      * Calculate the final path/filename and move the file to the new location.
      *
-     * Check also: doc/CODING.md
+     * Check also: Documentation-develop/CODING.md
      *
      * @param array $formElement FormElement 'upload'
      * @param string $sipUpload SIP
      * @param Sip $sip
      * @param string $modeUpload UPLOAD_MODE_UNCHANGED | UPLOAD_MODE_NEW | UPLOAD_MODE_DELETEOLD |
      *                            UPLOAD_MODE_DELETEOLD_NEW
-     *
      * @return false|string New pathFilename or false on error
      * @throws \CodeException
      * @throws \DbException
-     * @throws \UserFormException
-     * @throws \UserReportException
      * @throws \PhpOffice\PhpSpreadsheet\Exception
      * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
+     * @throws \UserFormException
+     * @throws \UserReportException
      * @internal param $recordId
      */
     private function doUpload($formElement, $sipUpload, Sip $sip, &$modeUpload) {
         $flagDelete = false;
         $modeUpload = UPLOAD_MODE_UNCHANGED;
+        $pathFileName = '';
 
         // Status information about upload file
         $statusUpload = $this->store->getVar($sipUpload, STORE_EXTRA);
@@ -781,15 +898,6 @@ class Save {
             $this->doImport($formElement, $tmpFile);
         }
 
-        // Upload - Take care the necessary target directories exist.
-        $cwd = getcwd();
-        $sitePath = $this->store->getVar(SYSTEM_SITE_PATH, STORE_SYSTEM);
-        if ($cwd === false || $sitePath === false || !HelperFile::chdir($sitePath)) {
-            throw new \UserFormException(
-                json_encode([ERROR_MESSAGE_TO_USER => 'getcwd() failed or SITE_PATH undefined or chdir() failed', ERROR_MESSAGE_TO_DEVELOPER => "getcwd() failed or SITE_PATH undefined or chdir('$sitePath') failed."]),
-                ERROR_IO_CHDIR);
-        }
-
         // Delete existing old file.
         if (isset($statusUpload[FILES_FLAG_DELETE]) && $statusUpload[FILES_FLAG_DELETE] == '1') {
             $arr = $sip->getVarsFromSip($sipUpload);
@@ -819,8 +927,6 @@ class Save {
             Logger::logMessageWithPrefix($msg, $this->qfqLogFilename);
         }
 
-        HelperFile::chdir($cwd);
-
         // Delete current used uniq SIP
         $this->store->setVar($sipUpload, array(), STORE_EXTRA);
 
@@ -828,6 +934,8 @@ class Save {
     }
 
     /**
+     * Excel Import
+     *
      * @param $formElement
      * @param $fileName
      * @throws \CodeException
@@ -977,7 +1085,7 @@ class Save {
             }
 
             // Import the data
-            foreach ($worksheetData AS $rowIndex => $row) {
+            foreach ($worksheetData as $rowIndex => $row) {
                 $columnList = '`' . implode('`,`', $columnListArr) . '`';
                 $paramPlaceholders = str_repeat('?,', count($worksheetData[0]) - 1) . '?';
                 $insertSql = "INSERT INTO `$tableName` ($columnList) VALUES ($paramPlaceholders)";
@@ -989,7 +1097,7 @@ class Save {
     /**
      * Copy uploaded file from temporary location to final location.
      *
-     * Check also: doc/CODING.md
+     * Check also: Documentation-develop/CODING.md
      *
      * @param array $formElement
      * @param array $statusUpload
@@ -1087,9 +1195,9 @@ class Save {
     }
 
     /**
-     * Check's if the file $pathFileName should be splitted in one file per page. If no: do nothing and return.
+     * Check's if the file $pathFileName should be split'ed in one file per PDF page. If no: do nothing and return.
      * The only possible split target file format is 'svg': fileSplit=svg.
-     * The splitted files will be saved under fileDestinationSplit=some/path/to/file.%02d.svg. A printf style token,
+     * The split'ed files will be saved under fileDestinationSplit=some/path/to/file.%02d.svg. A printf style token,
      * like '%02d', is needed to create distinguished filename's. See 'man pdf2svg' for further details.
      * For every created file, a record in table 'Split' is created (see splitSvg() ), storing the pathFileName of the
      * current page/file.
@@ -1221,7 +1329,7 @@ class Save {
      * Create/update or delete the slave record.
      *
      * @param array $fe
-     * @param $modeUpload
+     * @param string $modeUpload UPLOAD_MODE_NEW|UPLOAD_MODE_DELETEOLD_NEW|UPLOAD_MODE_DELETEOLD
      * @return int
      * @throws \CodeException
      * @throws \DbException