WordPress Settings API save post

The WordPress Settings API (docs) is used to create settings pages and save settings in the options table. But I didn’t need a setting, I needed a settings page to create/update a post.

Of course I could have created my own custom settings page and not use the settings API. But the settings API does have a couple of advantages.

  • Nonces validation.
  • Still using a WordPress standard.
  • Saving and error notices by default and customizable.
  • Mix with regular options.

First off create a settings page like we are used to:

<?php

add_action( 'admin_menu', 'register_menu_item' );

function register_menu_item() {
	add_submenu_page(
		'options-general.php',
		__( 'Settings page title' ),
		__( 'Menu title' ),
		'manage_options',
		'page-slug',
		function () {
			require './templates/settings-page.php';
		}
	);
}

add_action( 'admin_init', 'register_settings' );

/**
 * Register settings to save.
 */
function register_settings() {
	register_setting(
		'fake-option',
		'fake-option',
		array( 'sanitize_callback' => 'save_post' )
	);
}

/**
 * This function will only be called when the "option" is valid to save.
 * Here we validate our own fields and create the post we want.
 * 
 * @param null|array $unused_fake_setting
 */
function save_post( $unused_fake_setting ) {
	// Nonces are validated before we get here.

	static $first_run = true; // this function can be called twice, but we only need it once.
	if ( ! $first_run ) {
		return;
	}
	$first_run = false; // Never run again, this request.

	if ( empty( $_POST['log_title'] ) ) {
		add_settings_error( 'fake-option', 'title-empty', __( "Log title can't be empty." ) );

		return;
	}
	$title = wp_unslash( $_POST['log_title'] );
	$title = sanitize_text_field( $title );

	// validate other fields...
    
    
    // save the post.
	wp_insert_post( array(
		'post_title' => $title
		// add other attributes.
	) );

	// success message, yes the function name is confusing.
	add_settings_error( 'wpjm-ch-save', 'term-added', __( 'Worker successfully connected', 'success' ) );

}

And the templates/settings-page.php file:

<div class="wrap">
    <h1><?php esc_html_e( 'Settings page title' ); ?></h1>
    <form action="options.php" method="POST">
		<?php settings_fields( 'fake-option' ); ?>
        <h2><?php esc_html_e( 'Add log' ); ?></h2>

        <table class="form-table" role="presentation">
            <tbody>
            <tr>
                <th scope="row"><label for="log_title"><?php esc_html_e( 'Log title' ); ?></label></th>
                <td><input name="log_title" id="log_title" type="url" class="regular-text" required/></td>
            </tr>
            <tr>
                <th scope="row">More fields</th>
                <td></td>
            </tr>
            </tbody>
        </table>
		<?php submit_button( __( 'Add log' ) ); ?>
    </form>
</div>

How it works

We basically add settings like normal, except we skip a few things
First off normally you would add a section using add_settings_section in the same function where we register the settings field(s). But as we don’t really render a settings field in the template, we don’t add it at all. The same goes for the do_settings_sections in the Template.

In the settings template we do have the regular settings_fields this makes sure all the nonce fields are printed. And the submit_button.

Finally the thing we do extra and where the magic happens.
In the sanitize_callback of the register_setting function we set the save_post function.
Normally this is for sanitizing the option you want to save. But in this case we are not saving a option, we are saving a post. In this function we validate all the data send by the form, sanitize the data and save a new post.

And when the data is validated and saved we send a success notice with the add_settings_error function. Yes, we send a success notice with the **_error function. It can be done with the final argument, by setting the type to ‘success’.

Handling multiple custom options.

In the project that sparked this post I need to create a post, but also list those posts with a delete button.
What I found useful was creating a second register_setting, put them in two separate <form> tags on the template and let one handle the creating and the other the deleting.

<?php
function register_settings() {
	register_setting(
		'fake-option-create',
		'fake-option-create',
		array( 'sanitize_callback' => 'save_post' )
	);
	register_setting(
		'fake-option-delete',
		'fake-option-delete',
		array( 'sanitize_callback' => 'delete_post' )
	);
}
function save_post()  {
    // handles saveing/creating.
}
function delete_post() {
    // handles deleting.
}
?>


<div class="wrap">
    <h1><?php esc_html_e( 'Settings page title' ); ?></h1>
    <form action="options.php" method="POST">
		<?php settings_fields( 'fake-option-create' ); ?>
        HTML FORM HERE
		<?php submit_button( __( 'Add log' ) ); ?>
    </form>
    <form action="options.php" method="POST">
		<?php settings_fields( 'fake-option-delete' ); ?>
        LIST POSTS HERE WITH CHECKBOXES
		<?php submit_button( __( 'Delete logs' ) ); ?>
    </form>
</div>

Other tweaks.

?php_version=7.4&platform=wordpress&platform_version=5.6&software=free&software_version=15.6.2&days_active=30plus&user_language=en_US

  • Create, delete and updates posts (and post meta)
  • The same for user(meta and tax(meta)
  • External API calls.
  • Mix in regular use of the settings API.
  • World peace.

Install WordPress on Raspberry Pi

WordPress the CMS that doesn’t need introduction.In this guide we are going to install it and configure it a bit.
It is required you took the previous steps of setting up the Raspberry, installed php, nginx and mysql.

WordPress the CMS that doesn’t need introduction.In this guide we are going to install it and configure it a bit.

It is required you took the previous steps of setting up the Raspberry, installed php, nginx and mysql.

Setup Url

So far we’ve setup example.local we will create a second url and add configure it. I will use wordpress.local but feel free to use whatever you want.

First lets setup the correct directories.

sudo mkdir -p /var/www/wordpress.local/public_html
sudo chown www-data:www-data /var/www/wordpress.local -R
sudo nginx -t && sudo service nginx reload

Create the vhost and fill it with with the following.
sudo nano /etc/nginx/sites-enabled/wordpress.local.conf

server {
    listen 80;
    
    ## Your website name goes here.
    server_name wordpress.local www.wordpress.local;
    ## Your only path reference.
    root /var/www/wordpress.local/public_html;
    ## This should be in your http block and if it is, it's not needed here.
    index index.php;

    location = /favicon.ico {
            log_not_found off;
            access_log off;
    }

    location = /robots.txt {
            allow all;
            log_not_found off;
            access_log off;
    }

    location / {
            # This is cool because no php is touched for static content.
            # include the "?$args" part so non-default permalinks doesn't break when using query string
            try_files $uri $uri/ /index.php?$args;
    }

    location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
            expires max;
            log_not_found off;
    }
    
    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
        fastcgi_intercept_errors on;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
    }
}

This config is the bare minimum as recommended by the nginx manual.
It works but it is not the best. The official WordPress docs has some more examples.
The main thing to improve is security.
I might do a better config in the future but for now this is all.

For the final step reload nginx.

sudo nginx -t && sudo service nginx reload

Create DB

Login to the mysql prompt:

sudo mysql -uroot

After that run the following lines to create a database, create a user with a strong password and assign permissions.

CREATE DATABASE raspimain_db;
CREATE USER 'raspimain_user'@'localhost' IDENTIFIED BY 'P@SSW0RD_Str0nG_R@nD0m_&_L0ng';
GRANT ALL PRIVILEGES ON `raspimain_db`.* TO `raspimain_user`@`localhost`;
FLUSH PRIVILEGES;
EXIT;

Download WordPress

Go to the new WordPress directory.

cd /var/www/wordpress.local/public_html/

Download the latest version of WordPress, and unzip it.

sudo wget https://wordpress.org/latest.zip
sudo unzip latest.zip
sudo mv wordpress/* ./
sudo rm -r wordpress latest.zip
sudo chown www-data:www-data . -R

Install WordPress

A few things to check after the install is done.

Gravity Forms give editors access

By default Gravity forms isn’t accessible by editors. There are ways to do this with plugins like members.
As some might guess I prefer wp-cli for this:

wp cap add editor gravityforms_create_form gravityforms_edit_forms gravityforms_view_entries gravityforms_export_entries gravityforms_delete_entries graviyforms_delete_forms gravityforms_edit_entries gravityforms_view_entry_notes gravityforms_edit_entry_notes --grant
Difference in rights after setting capabilities

Running this will pretty much give all rights except the settings of Gravifty Forms.
It will allow creatign, editing & deleting forms. But also view entries and export them.

Full list of Gravity Forms Capabilities

WordPress the_date skipping same days

Lets take a look at this very basic loop.

<?php
$query_name = new WP_Query();

if ( $query_name->have_posts() ) :
    while ($query_name->have_posts()): $query_name->the_post();
        the_date();
        the_title();
        the_content();
    endwhile;
endif;

Nothing special right? Will by default just display the date, title and content of the first 10 posts.
But if 2 posts are published on the same day it will skip display that posts date.

When looking at the source code of the_date it compares the date of the previous post with the current using is_new_day.
I guess it can make sense in some scenario’s but too me it’s a bit weird by default.

To prevent this just use

<?php
echo get_the_date();

WordPress filters and anonymous functions

Anonymous functions have been around for a long time. And since WordPress now supports php 5.6 it can be safely used.
And appears to be allowed?

Personally I’m not a fan of anonymous functions in combination with WordPress actions and filters. My main concern is you can’t remove them once registered. How ever today I found a use case which was very usefull in combination with the use

My example:

<?php
/* Template name: some-template */

// Gather all data
$condition_for_title = true;
$h1_title_override = 'very heavy and complicated check';
// the H1 also needed as the <title>

add_filter( 'pre_get_document_title', function( $title ) use ($h1_title_override, $condition_for_title) {
    if ($condition_for_title) {
        return $h1_title_override;
    }
    return $title;
}, 20, 1 );

get_header();

// start body
?>
    <h1><?php echo $h1_title_override ?>

Here I pass 2 variables h1_title_override and $condition_for_title which are created outside the function. In my case these where quite complicated and heavy checks. Of course I could put those in a function and cache the result. And call that check in the filter function. But still I need to check the current template before doing the function.

More traditional Example:

in functions.php

<?php
function complicated_check() {
    // Gather all data
    $condition_for_title = true;
    $h1_title_override   = 'very heavy and complicated check';

    return [
        'condition_for_title' => $condition_for_title,
        'h1_title_override'   => $h1_title_override,
    ];
}

function title_exception_for_template( $title ) {
    if ( ! is_page_template('clean-template.php')) {
        return $title;
    }

    $template_data = complicated_check();

    if ( $template_data['condition_for_title'] ) {
        return $template_data['h1_title_override'];
    }

    return $title;
}

add_filter( 'pre_get_document_title', 'title_exception_for_template', 20, 1 );

in clean-template.php

<?php
/* Template name: clean-template */
$template_data = complicated_check();

get_header();

// start body
?>
    <h1><?php echo $template_data['h1_title_override'] ?>

Both these approaches do the same thing. But the more traditional way is a lot more code. Although it has cleaner template.
I probably won’t use this much. If the anonymous function was more complicated it will get hard to read.

But for this case I think it was neat that I could use this little feature.

Meta query order of arrays

The site had 20K plus posts/pages/ect, freshly put live.

The hoster called. It had slow queries, 9 seconds on average. A lot, multiple per minute. The same query over and over again.
It was a pretty normal post table query. It had one part extra.
A meta query was to exclude all spot_closed posts. When I removed that it was quick. It didn’t show anything special.

The meta query was added as such:

<?php
$meta_query_spots = [
    'relation'     => 'OR',
    [
        'key'     => 'spot_closed',
        'value'   => 1,
        'compare' => '!=',
    ],
    [
        'key'     => 'spot_closed',
        'compare' => 'NOT EXISTS',
    ],
];

Check if the spot is closed, and if it a spot does not have a close status.
The fix, flip the checks. And bam query less then half a second.

<?php
$meta_query_spots = [
    'relation'     => 'OR',
    [
        'key'     => 'spot_closed',
        'compare' => 'NOT EXISTS',
    ],
    [
        'key'     => 'spot_closed',
        'value'   => 1,
        'compare' => '!=',
    ],
];

WordPress can’t use number as page slugs

Where is what we wanted. A page example.com/404
So we had a page in WordPress so the content was controllable.

But on save the slug was changed to 404-2.

I figured maybe that slug was preserved. So after some digging I found this beautiful *ahem* if statement in core.

<?php
if (
    $post_name_check || in_array( $slug, $feeds )
    || 'embed' === $slug
    || preg_match( "@^($wp_rewrite->pagination_base)?\d+$@", $slug )
    || apply_filters( 'wp_unique_post_slug_is_bad_hierarchical_slug', false, $slug, $post_type, $post_parent )
)
{ //....

The part it fails on is the preg_match. No number is accepted.
So it’s not just the slug 404 but every number.

The reason for this because it preserves numbers for pagination (including in-page pagination).

So the solution was to rename the page to example.com/404-page

WordPress Action Priority

I wanted to add_action between 2 different actions.
These actions where priority 3 and 4. So I tried 3.5. That didn’t work.

Decimals get rounded down. What did work was the string “3.5”

This got me curious about strings and the sorting in general. Under the hood WP just uses array_keys
To make things easier here is a list of use cases:

WordPress action how a priority is sorted

ACF load user_meta cross WordPress multisite

Users are global in a multisite. And default ACF groups are per site. So if you set a pinterest url for a user on one site it will apply it for all sites. No problem. But if you set an image it will try to find that image-id on each site.

The solution.

Step 1; Limit the user acf groups only to the main site:

<?php
function wpstarter_filter_fields_group($result, $rule, $screen) {
    if ( isset( $screen['user_id'] ) && ! is_main_site() )
        return false;

    return $result;
}
add_filter('acf/location/rule_match', 'wpstarter_filter_fields_group', 10, 3);

Step 2; Get image fields for users only from the main site:

<?php
function wpstarter_load_avatar_value_cros_site( $value, $post_id, $field ) {

    if( 0 !== strpos( $post_id, 'user_' ) || is_main_site() ) {
        return $value;
    }

    switch_to_blog( get_main_site_id() );

    $value_of_main_site = acf_get_value($post_id, $field);
    $value              = acf_format_value($value_of_main_site, $post_id, $field);

    restore_current_blog();
    return $value;
}
add_filter( 'acf/format_value/type=image', 'wpstarter_load_avatar_value_cros_site', 11, 3);