WordPress provides two mechanisms for handling custom form submissions from the admin area without building a full REST API endpoint: admin-ajax.php (for asynchronous requests that return partial content) and the admin_post_{action} hook (for traditional synchronous form submissions that result in a page redirect). The admin_post_{action} system routes POST requests to wp-admin/admin-post.php based on an action hidden field in the form, runs the registered callback, and then expects the callback to redirect the user. This pattern is the correct way to handle: bulk action forms, settings exports, file imports, one-click operations that trigger long-running processes, and any admin form that does not need JavaScript. It is simpler than the REST API for pure form-submit workflows and does not require any JavaScript at all.
Problem: An admin page has a "Sync Products" button that triggers a long-running CSV import. The form should submit to the server, the callback should process the import and store results in a transient, then redirect back to the admin page with a success or error query parameter displayed as an admin notice.
Solution: Use an HTML form posting to admin-post.php with an action field, a nonce field, and a wp_admin_post_{action} hook that processes the request and calls wp_redirect().
<?php
// ── 1. Render the admin page with the form ────────────────────────────
function render_sync_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Forbidden.' );
}
// Show notice from previous redirect
$status = sanitize_key( $_GET['sync_result'] ?? '' );
if ( 'success' === $status ) {
$count = absint( $_GET['count'] ?? 0 );
printf( '<div class="notice notice-success"><p>Imported %d products.</p></div>', $count );
} elseif ( 'error' === $status ) {
echo '<div class="notice notice-error"><p>Import failed. Check error log.</p></div>';
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'Product Sync', 'textdomain' ); ?></h1>
<form method="POST" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<!-- Required: identifies which admin_post_ hook to fire -->
<input type="hidden" name="action" value="my_plugin_sync_products">
<!-- Nonce for CSRF protection -->
<?php wp_nonce_field( 'my_plugin_sync_products', 'sync_nonce' ); ?>
<input type="submit" class="button button-primary"
value="<?php esc_attr_e( 'Sync Products Now', 'textdomain' ); ?>">
</form>
</div>
<?php
}
// ── 2. Register the action handler ────────────────────────────────────
// wp_ajax_{action} = logged-in users via admin-ajax.php (AJAX pattern)
// admin_post_{action} = logged-in users via admin-post.php (form submit pattern)
// admin_post_nopriv_{action} = non-logged-in users (rare for admin forms)
add_action( 'admin_post_my_plugin_sync_products', 'handle_my_plugin_sync' );
function handle_my_plugin_sync(): void {
// 1. Verify nonce
if ( ! check_admin_referer( 'my_plugin_sync_products', 'sync_nonce' ) ) {
wp_die( 'Security check failed.' );
}
// 2. Check capability
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Forbidden.' );
}
// 3. Do the work
$result = run_product_import(); // returns ['count' => 42] or WP_Error
// 4. Redirect back with result
$admin_url = admin_url( 'admin.php?page=my-plugin-sync' );
if ( is_wp_error( $result ) ) {
wp_safe_redirect( add_query_arg( 'sync_result', 'error', $admin_url ) );
} else {
wp_safe_redirect( add_query_arg( [
'sync_result' => 'success',
'count' => $result['count'],
], $admin_url ) );
}
exit;
}
NOTE: Always call exit immediately after wp_safe_redirect() (or wp_redirect()). Without exit, WordPress continues executing the callback after the redirect header is sent, which can cause double output or security issues. Use wp_safe_redirect() instead of wp_redirect() — it validates that the target URL is on the same host or in the allowed redirect hosts list, preventing open redirect vulnerabilities. The add_query_arg() return value passed to wp_safe_redirect() should always be escaped with esc_url_raw() if the base URL comes from user input — but for admin URLs built from admin_url(), this is not needed. The admin_post_nopriv_{action} variant fires for non-authenticated users, but most admin forms should never be accessible to unauthenticated users — always include a capability check.