‘Headers already sent’ Error When Redirecting from add_submenu_page() Callback

Background

The infamous “Headers already sent” error rears it’s ugly head in circumstances where something attempts to modify the HTTP headers for the server’s response after they have already been dispatched to the browser – that is to say, when the server should only be generating the body of the response.

This often happens in one of two ways:

  • Code prints things too early before WordPress has finished composing headers – this pushes the HTTP response to the browser and forces the HTTP headers to be dispatched in the process. Any attempts by WordPress or third-party code that would normally properly modify the headers will subsequently produce the error.
  • Code attempts to alter HTTP headers too late after WordPress has already sent them and begun to render HTML.

Problem & Solution Overview

This instance, the problem is the latter. This is because the wp_safe_redirect() function works by setting a 300-range HTTP status header. Since the callback function arguments for add_menu_page() and add_submenu_page() are used to print custom markup after the dashboard sidebar and floating toolbar have already been generated, the callbacks are too late in WordPress’s execution to use the wp_safe_redirect() function. As discussed in the comments, the solution is to move the $wpdb->update() and wp_safe_redirect() calls outside of your views/edit.php view template and “hook” them to an action that occurs before the HTTP headers are sent (before any HTML is sent to the browser).


Solving the Problem

Looking at the action reference for the typical admin-page request, it’s reasonable to assume that the 'send_headers' action is the absolute latest that HTTP headers can be reliably modified. So your desired outcome could be achieved by attaching a function to that action or one before it containing your redirect logic – but you’d need to manually check which admin page is being displayed to ensure that you only process the $wpdb->update() and subsequent redirect for your views/edit.php template.

Conveniently, WordPress provides a shortcut for this contextual functionality with the 'load-{page hook}' action, which is dynamically triggered for each admin page loaded. While your call to add_submenu_page() will return the {page hook} value needed to hook an action for execution when your custom templates load, page hooks can be inferred in the format {parent page slug}_page_{child page slug}. Taking the values from your code, then, we end up with the 'load-bv_before_afters_main_page_bv_before_afters_edit' action, which will only execute while loading your views/edit.php template.

The final issue becomes the matter of passing along whether or not $wpdb->update() produced an error to your views/edit.php template to determine whether or not to display the error message. This could be done using global variables or a custom action, however a better solution is to attach a function that will print your error to the 'admin_notices' action which exists for exactly this reason. 'admin_notices' will trigger right before WordPress renders your custom view template, ensuring that your error message ends up on the top of the page.

Note

The $wpdb->update() method returns the number of rows updated or false if an error is encountered – this means that a successful update query can change no rows and return 0, which your if() conditional will lazily evaluate as false and display your error message. For this reason, if you wish to only display your error when the call actually fails, you should compare $wpdb->update()‘s return value against false using the identity comparison operator !==.


Implementation

Combining all of the above, one solution might look as follows. Here I’ve used an extra array to hold the page slugs for your admin pages for the sake of easy reference, ensuring no typo-related issues and making the slugs easier to change, if necessary.

The entire portion of the views/edit.php file that you originally posted would now reside in the action hook, as well as any other business logic used to process the edit form.

Plugin File:

$bv_admin_page_slugs = [
  'main' => 'bv_before_afters_main',
  'edit' => 'bv_before_afters_edit',
  'add'  => 'bv_before_afters_add'
];

add_action( 'admin_menu', 'bv_register_admin_pages' );

// Create page in WP Admin
function bv_register_admin_pages() {
    add_menu_page(
      'Before & After Photos',
      'Before & Afters',
      'edit_posts',
      $bv_admin_page_slugs['main'],
      'bv_before_afters_main',
      'dashicons-format-gallery',
      58
    );

    add_submenu_page(
      $bv_admin_page_slugs['main'],
      'Add set',
      'Add set',
      'edit_posts',
      $bv_admin_page_slugs['add'],
      'bv_before_afters_add'
    );

    add_submenu_page(
      $bv_admin_page_slugs['main'],
      'Edit set',
      'Edit set',
      'edit_posts',
      $bv_admin_page_slugs['edit'],
      'bv_before_afters_edit'
    );    
}

// Process the update/redirect logic whenever the the custom edit page is visited
add_action(
  'load-' . $bv_admin_page_slugs['main'] . '_page_' . $bv_admin_page_slugs['edit'],
  'bv_update_before_after'
);

// Performs a redirect on a successful update, adds an error message otherwise
function bv_update_before_after() {
  // Don't process an edit if one was not submitted
  if( /* (An edit was NOT submitted) */ )
    return;

  $result = $wpdb->update( /* (Your Arguments) */ );

  if( $result !== false ) {
    wp_safe_redirect('/wp-admin/admin.php?page=" . $bv_admin_page_slugs["main'] . '&updated=1');
    exit;
  }
  else {
    // Tell WordPress to print the error when it usually prints errors
    add_action(
      'admin_notices',
      function() {
        echo '<div class="notice notice-error is-dismissible"><p>Could not be updated!</p></div>';
      }
    );
  } 
}

// Apply stylesheets
function bv_before_afters_styles(){
    wp_enqueue_style( 'bv_before_after_styles', plugins_url( '/bv_before_afters.css', __FILE__ ) );
}
add_action('admin_print_styles', 'bv_before_afters_styles');

// Display the main page
function bv_before_afters_main(){
    include_once plugin_dir_path( __FILE__ ).'/views/view_all.php';
}

// Display the edit page
function bv_before_afters_edit(){
    include_once plugin_dir_path( __FILE__ ).'/views/edit.php';
}

// Display the add page
function bv_before_afters_add(){
    include_once plugin_dir_path( __FILE__ ).'/views/add.php';
}