Contents
Introduction
This article is a complete, practical tutorial showing multiple ways to export CSV files from the WordPress admin using PHP. It covers secure implementation patterns, performance for large data sets, CSV formatting considerations (including Excel compatibility and character encoding), and several ready-to-copy code examples for different use cases: exporting posts (with meta and taxonomies), exporting users, and implementing an Export button that streams results to the browser.
Prerequisites and assumptions
- WordPress site with PHP access (plugin or theme functions.php).
- Ability to add PHP code (create a small plugin is recommended).
- Basic knowledge of WordPress hooks and capabilities.
- This tutorial assumes you will run code in the admin area and that you will protect exports behind appropriate capability checks (e.g. manage_options or edit_posts depending on data).
High-level approaches
- Admin-post handler: submit a form to admin-post.php?action=… and stream CSV from a handler hooked to admin_post_.
- Direct admin page: an admin menu/submenu with a button that triggers a download (internally routes to the same handler above).
- AJAX-triggered export: use wp_ajax to start the process and redirect the browser to an export URL. Not ideal for large downloads because AJAX response cannot stream a download directly.
- Bulk action: add a custom bulk action to the post list table to export selected items.
CSV fundamentals and browser/download headers
When streaming CSV from PHP you must:
- Send the correct HTTP headers before any output (Content-Type, Content-Disposition).
- Ensure there is no whitespace or other output before headers. Use output buffering or clear existing buffers.
- Set UTF-8 BOM if you expect users to open CSV in Excel on Windows (optional but common): the three bytes 0xEF 0xBB 0xBF.
- Use fputcsv to reliably escape commas, quotes and handle enclosures.
- Exit immediately after sending the file to avoid the rest of WordPress printing markup.
Recommended headers
Example headers to send before CSV output:
- Content-Type: text/csv charset=UTF-8
- Content-Disposition: attachment filename=export-YYYY-mm-dd_hhii.csv
- Pragma: public
- Cache-Control: must-revalidate, post-check=0, pre-check=0
- Content-Description: File Transfer
Security: capability checks and nonces
Always:
- Check current_user_can(capability) appropriate for the data you are exporting (e.g. manage_options for site admins, edit_posts for editors exporting posts).
- Validate a nonce on form submissions to prevent CSRF.
- Sanitize and validate any user-supplied parameters (dates, post types, IDs).
Implementation pattern: admin-post.php handler (recommended)
This pattern is simple and works with admin forms. You create a form on an admin page that posts to admin-post.php?action=your_action, then implement the handler using add_action(admin_post_your_action, your_handler). Use admin_post_nopriv_… only for non-logged-in users (not recommended for admin exports).
Full example: Export posts with selected columns, meta and taxonomies
Place this in a small plugin file or in functions.php (plugin recommended). This example adds an admin menu, shows a form with nonce and post type selection, and streams a CSV when the form is submitted.
lt?php / Plugin Name: Admin CSV Export Example Description: Example code to export posts to CSV via admin-post handler. / // Add an admin submenu page add_action(admin_menu, function() { add_submenu_page( tools.php, // parent slug Export Posts CSV, // page title Export Posts CSV, // menu title edit_posts, // capability export-posts-csv, // menu slug apce_render_export_page // callback ) }) function apce_render_export_page() { // Minimal UI: a form with nonce that posts to admin-post.php action = apce_export_posts nonce = wp_create_nonce(apce_export_posts_nonce) ?> ltform method=post action=gt ltinput type=hidden name=action value= /gt ltinput type=hidden name=apce_nonce value= /gt ltpgt ltlabelgtPost type:lt/labelgt ltselect name=post_typegt ltoption value=postgtPostlt/optiongt ltoption value=pagegtPagelt/optiongt lt/selectgt lt/pgt ltpgtltbutton class=button button-primary type=submitgtExport CSVlt/buttongtlt/pgt lt/formgt lt?php } // Handle export on admin_post_{action} add_action(admin_post_apce_export_posts, apce_handle_export_posts) function apce_handle_export_posts() { // Capability nonce checks if ( ! current_user_can(edit_posts) ) { wp_die(Insufficient permissions) } if ( empty(_POST[apce_nonce]) ! wp_verify_nonce(_POST[apce_nonce], apce_export_posts_nonce) ) { wp_die(Invalid nonce) } // Sanitize inputs post_type = isset(_POST[post_type]) ? sanitize_key(_POST[post_type]) : post if ( ! post_type_exists(post_type) ) { wp_die(Invalid post type) } // Prepare filename filename = sanitize_file_name( post_type . -export- . date(Y-m-d_His) . .csv ) // Clear any output buffers to avoid corrupting the CSV while ( ob_get_level() ) { ob_end_clean() } // Send headers header(Content-Type: text/csv charset=UTF-8) header(Content-Disposition: attachment filename= . filename . ) header(Pragma: public) header(Cache-Control: must-revalidate, post-check=0, pre-check=0) header(Content-Description: File Transfer) // Optional: send BOM for Excel compatibility echo xEFxBBxBF // Open output stream output = fopen(php://output, w) // Header row for CSV columns = array( ID, post_title, post_type, post_status, post_date, post_author, permalink ) fputcsv(output, columns) // Query posts in batches to limit memory usage paged = 1 posts_per_page = 200 do { query = new WP_Query(array( post_type => post_type, post_status => any, posts_per_page => posts_per_page, paged => paged, no_found_rows => true, // improve performance fields => ids // we will get post data individually (or change as needed) )) if ( ! query->have_posts() ) { break } foreach ( query->posts as post_id ) { // Get core fields post = get_post(post_id) author = get_the_author_meta(display_name, post->post_author) permalink = get_permalink(post) // Example: add a piece of meta and comma-safe fields custom_meta = get_post_meta(post_id, subtitle, true) row = array( post->ID, post->post_title, post->post_type, post->post_status, post->post_date, author, permalink ) // Add an extra meta column at the end row[] = custom_meta fputcsv(output, row) } // free memory wp_reset_postdata() // Break when fewer posts returned than requested if ( count(query->posts) < posts_per_page ) { break } paged // Optional: keep script running for long exports @set_time_limit(0) // flush output buffer to push data to browser periodically if ( function_exists(flush) ) { flush() } } while ( true ) fclose(output) exit } ?gt
Explanation of the above code
- We add a submenu under Tools to present a simple UI and a form. The form posts to admin-post.php which triggers the admin_post action.
- Handler function performs capability and nonce checks, sanitizes inputs, sets safe headers, prints an optional BOM and uses php://output with fputcsv to stream rows.
- We purposely query posts in batches using paged posts_per_page and no_found_rows to limit memory use. This is critical for large exports.
- Always call exit after sending the file to stop WordPress from continuing to render the admin page or printing HTML.
Alternative: Bulk action to export selected posts
To export selected posts from the post list table you can register a custom bulk action and then hook into handle_bulk_actions-post to process selected IDs and stream CSV. Below is a minimal example to register a bulk action and a handler. For large selections you should still process in batches and ensure capability checks and nonce verification if relevant.
lt?php // Add bulk action to the posts list add_filter(bulk_actions-edit-post, function(bulk_actions) { bulk_actions[export_to_csv] = Export to CSV return bulk_actions }) // Handle the bulk action add_filter(handle_bulk_actions-edit-post, function(redirect_to, doaction, post_ids) { if (doaction !== export_to_csv) { return redirect_to } if (! current_user_can(edit_posts)) { wp_die(Insufficient permissions) } // Create a temporary filename filename = posts-export- . date(Y-m-d_His) . .csv while (ob_get_level()) { ob_end_clean() } header(Content-Type: text/csv charset=UTF-8) header(Content-Disposition: attachment filename= . filename . ) echo xEFxBBxBF output = fopen(php://output, w) fputcsv(output, array(ID,Title,Status,Date)) foreach (post_ids as post_id) { p = get_post(post_id) fputcsv(output, array(p->ID, p->post_title, p->post_status, p->post_date)) } fclose(output) exit }, 10, 3) ?gt
Large exports: performance considerations
- Use SQL-level queries via wpdb with LIMIT/OFFSET for very large exports when WP_Query is too slow. But be careful to sanitize values and to only select the columns you need.
- Process data in batches (e.g., 100–1000 rows) and flush output between batches to avoid memory exhaustion.
- Disable expensive WordPress features during the export: set no_found_rows => true, update_post_meta_cache => false, and update_post_term_cache => false in WP_Query when appropriate.
- Increase max execution time where necessary: set_time_limit(0). Avoid infinite loops—always ensure batch loop termination.
- Consider asynchronous background exports for very large datasets: enqueue a background job (WP Cron, Action Scheduler, or an external queue) that writes CSV to the server file system and notifies the admin when ready then provide a secure download link. This avoids PHP timeout issues and web-server connection problems.
CSV formatting details and pitfalls
- Enclosure and escaping: use fputcsv which takes care of quoting and escaping. Do not build CSV rows by hand unless you fully handle quoting.
- Commas inside fields are common: fputcsv will quote those fields automatically.
- Newlines inside fields: fputcsv will quote the field and preserve the newline. Many spreadsheet apps will show it correctly.
- Character encoding: send UTF-8 and optionally BOM (0xEF,0xBB,0xBF) for Excel. Be aware that Excel on Windows historically expects ANSI/Windows-1252 for CSV—UTF-8 with BOM modern versions of Excel detect UTF-8 correctly.
- Decimal separators and locales: numbers and dates can be formatted to the users locale prior to exporting.
- CSV delimiter: fputcsv defaults to comma. Some locales prefer semicolon you can pass a custom delimiter to fputcsv as the 3rd parameter.
Example: Export users with roles and meta
Below is an example that streams users and some meta values. Adjust the columns and meta keys to your needs.
lt?php // Example handler to export users to CSV on admin request (hook or admin page) function export_users_csv_handler() { if ( ! current_user_can(list_users) ) { wp_die(Insufficient permissions) } // Clear output while (ob_get_level()) { ob_end_clean() } filename = users-export- . date(Y-m-d_His) . .csv header(Content-Type: text/csv charset=UTF-8) header(Content-Disposition: attachment filename= . filename . ) echo xEFxBBxBF output = fopen(php://output, w) // header row fputcsv(output, array(ID,user_login,display_name,user_email,roles,registered,custom_meta)) args = array( number => 200, paged => 1 ) do { user_query = new WP_User_Query(args) users = user_query->get_results() if ( empty(users) ) { break } foreach (users as user) { roles = implode(, user->roles) registered = user->user_registered custom_meta = get_user_meta(user->ID, some_meta_key, true) fputcsv(output, array(user->ID, user->user_login, user->display_name, user->user_email, roles, registered, custom_meta)) } args[paged] if ( function_exists(flush) ) { flush() } } while ( count(users) === args[number] ) fclose(output) exit } ?gt
Error handling and debugging tips
- Do not print debug messages or var_dump before or during sending headers — that will corrupt the CSV. Instead, write debug logs to a file or error_log.
- If you see headers already sent errors, search for whitespace or output produced earlier (BOM in PHP files, stray echoes, or whitespace outside PHP tags).
- Use small test exports before running huge exports. Add timeouts and limits to detect potential infinite loops.
Serving CSV files saved on the server vs streaming
- Streaming (php://output): immediate download, memory-efficient if batched and flushed, does not create server files.
- Saving to server file: safer for very large exports or if you need to hand the file to other systems. Use WP Filesystem API or direct file writes to a safe directory, then provide a secure link removing the file after download or storing it for a limited time.
Useful references
- WP_Query
- admin_url()
- Nonces and wp_verify_nonce
- WP_User_Query
- wpdb (for advanced custom SQL)
Checklist before deploying into production
- Ensure capability checks correspond to the exported data and that only authorized accounts can export.
- Sanitize and validate input parameters (post types, dates, filters).
- Test with different encodings and open the resulting CSV in Excel, LibreOffice, and Google Sheets.
- Test export with a large dataset to confirm performance and memory usage.
- If files are saved to disk, secure the storage location and remove old files automatically.
Summary
Exporting CSV from the WordPress admin with PHP is straightforward when you follow secure patterns: use admin-post handlers or custom admin pages, perform capability checks and nonce validation, stream CSV with fputcsv and php://output, and process rows in batches for large datasets. For extremely large exports, consider background jobs that produce files and provide a secure download link.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |