Building an ACF-Based Grade Book Plugin in 30 Minutes or Less

Origin Story

Kathy asked me if I had any grade book plugins that they could use with a faculty development course. The goal was to show faculty where they were in the course. I knew I did not wish to use Learn Dash for any number of reasons– grade book module costs extra, grade book module is super awkward, learn dash requires a fresh multisite install etc. I also had WPLMS from a few years ago when I think Jon asked to try it out. This felt pretty bloated for what we wanted and it wasn’t entirely obvious if it had a grade book in any case. The WordPress plugin repository shows three plugins and the most up to date has not been updated in 3 years.

Not a cheerful landscape. I wondered what it would take to build an ACF-based grade book relying mostly on the repeater field and simple shortcode for displaying the grades to the user. I wasn’t looking to do math or anything. I just wanted a pretty simple interface and a way for users to see their information while administrators could see all the content. That does require people to have accounts and be logged in. A bit of hassle but there is no free lunch.1 This grade book isn’t adding up things or doing weird weighting etc. It’s just a simple way to log people + assignments + scores.2 It is now known as progress book and it lives on github.

The ACF Side

We can do just about everything I can think of with one repeater field as the parent. The sub-fields will be the participant’s name, the assignment name, and the score on a scale of 1 to 100. Since we’re requiring participants to be member of the site, the participant field will be a relationship field tied to the site users. That makes the list of students automatic and makes for an easy way to recognize them when we want to show them their progress. The assignment field is a select field that has the assignments pre-populated. You’ll have to go into ACF to add assignments which isn’t optimal but the only field that lets you add choices in that interface is the checkbox field. That ends up taking up too much room if you’ve got 10 or so assignments. In this case, we should know the assignments ahead of time and even if alternations are needed, adding them via ACF is pretty straightforward. The final piece is just a slider using the range field.

That’s it. Maybe ten minutes of work with the majority of it looking at the assignments piece and trying to find a slightly nicer way to add custom values on the fly. You end up with an interface like the one below that I set to show when a page is called grade book.

One other nice piece I added was the json sync function for the plugin. It takes an extra step compared to doing this in a theme but it keeps your ACF fields nice and sync’d between development and production. This is very handy and prevents all kinds of hassle.

add_filter('acf/settings/save_json', 'my_acf_json_save_point');
function my_acf_json_save_point( $path ) {
    // update path
    $path = plugin_dir_path( __FILE__ )  . '/acf-json';
    // return
    return $path;

add_filter('acf/settings/load_json', 'my_acf_json_load_point');

function my_acf_json_load_point( $paths ) {
    // remove original path (optional)
    // append path
    $paths[] = plugin_dir_path( __FILE__ )  . '/acf-json';
    // return
    return $paths;

Displaying Grades Shortcode

The following chunk of PHP is how we display the ACF data on the front end. It’s tied to a shortcode and builds a little table-like display and uses a tiny bit of logic to show either just your stuff if you’re not an admin or all if the stuff if you are an admin– current_user_can(‘admin’) || get_current_user_id() === get_sub_field(‘student’).

function get_progress_by_user(){
		$html = '';
		if( have_rows('progress') ):
	 	// loop through the rows of data
		$html .= '<div id="your-progress" class="progress-row"><div class="progress-title">Name</div><div class="progress-title">Assignment</div><div class="progress-title">Score</div></div>';
	    while ( have_rows('progress') ) : the_row();
	    	if (current_user_can('admin') || get_current_user_id() === get_sub_field('student')){
		    	$stu_id = get_sub_field('student');
		    	$name =  get_userdata(get_sub_field('student'))->display_name;
		    	$safe_name =  sanitize_title(get_userdata(get_sub_field('student'))->display_name);
		    	$safe_assign = sanitize_title(get_sub_field('assignment'));
		        // display a sub field value
		        $html .= '<div class="progress-row ' . $safe_name . ' ' . $safe_assign .'">';
		        $html .= '<div class="progress-student" data-student="' . $safe_name . '">' . $name . '</div>';
		        $html .= '<div class="progress-assignment" data-assign="' . $safe_assign . '">' . get_sub_field('assignment') . '</div>';
		        $html .= '<div class="progress-grade">' . get_sub_field('grade') . '</div>';
		        $html .= '</div>';


	    return $html;

	else :

	    // no rows found



To make things a little easier to parse for the admins, I added a little javascript that lets you click on a participant name to hide everyone else or to click on an assignment name to hide all the other assignments.

if (document.querySelectorAll('.progress-assignment')){
	  el.addEventListener('click', function() {

	function hideStudents(theStudent){
		let names = document.querySelectorAll('.progress-student');
			if (student.dataset.student != theStudent){

	  el.addEventListener('click', function() {

	function hideAssign(theAssign){
		let names = document.querySelectorAll('.progress-assignment');
			if (assign.dataset.assign != theAssign){

1 That’s actually how I measure how bad an experience will be. If they’re willing to give you free food as bait . . . expect the worst.

2 It wouldn’t take a ton of work to figure all that out but I’m not going to encourage the facade that grading requires that kind of complexity. It’s like building a custom kitchen so you can keep making instant noodles. All the fancy math in the world isn’t going to fix the fact that assessment is fundamentally broken.

Gravity Forms to ACF Pattern

When you use Gravity Forms to make a post, you can provision ACF fields but what I found was that the data wasn’t showing up correctly until I manually went and updated the created post. I tried using the WordPress wp_update_post() function but found that didn’t do it.

I ended up taking a look at the post_meta in the database directly.1 That’s something that I find myself doing more and more. When you can look directly at evidence, do that. Don’t assume. So what I saw was this . . .
Screenshot of Sequel Pro showing the metadata associated with the post that Gravity Forms created.
You can see that the base custom fields are there. The data is visible.

Now I hit update and refreshed the database view and saw lots of new custom fields get generated. This data associates the human readable fields with the field keys that ACF creates. Note the underscores which prevent those fields from showing up in the backend of WP even if you have view custom fields selected.
New view showing all the new metadata fields that get generated.

I feel like this has something to do with acf/save_post but couldn’t figure out how to make that work. When my knowledge fails,2 I resort to force.

First, I turned on the ability to see the field keys in ACF. It’s easy to miss and easy to forget that this is a Screen Option in WP.
Showing the screen option at the top of the page that allows you to see the ACF field keys.

Once I could see that clearly, I felt typing in all that junk would take a while and be a place where I could make dumb errors. So I cut and pasted the data into a Google Sheet and used my old text manipulation tools to build the function that would build the text I needed.


That was cut/pasted into the plugin and hooked to the after submission hook that Gravity Forms has.

add_action( 'gform_after_submission_1', 'asphs_update_post_content', 10, 2 );

That triggers this function and everything now behaves.

function asphs_update_post_content( $entry, $form ) {
    //getting post
        $post_id = get_post( $entry['post_id'] );
    $i = wp_update_post( $post_id );


1 I use Sequel Pro.

2 And many, many Google searches . . .

Starting to Think Through a Mapping Theme with ACF

We’re working with Dr. Nicole Turner on a mapping site that will accompany her upcoming book. There’s a lot of specifics there which we’re considering while trying to walk the fine line where what we make is also something we’ll be able to use with other people down the road. We want to generalize but not too much. I’m sketching out some early thinking here as a way to document it personally and to share it with Jeff (who’s thinking through the javascript side of things).1

Thinking About the Data

It seems that any mapping project would have three basic data types– People, Locations, and Events. People because humans are usually important in these scenarios. Locations being important in mapping and events for things that have limited duration. Matt described it well as the Who, When, and Where.

Those types could be associated with each other in multiple ways. A person might be associated with various locations and various events. Events might involve various people in various places. If I think too hard I’ll make this more complex.

The Custom Post Types & ACF Structure

I feel relatively good about those three main types so breaking down the details of what those big boxes should contain was the next consideration.


  • First Name
  • Middle Name
  • Last Name
  • Title
  • Description/Bio
  • Birth Event*
  • Death Event*
  • Events*
  • Locations*
  • Categories
  • Featured Image


  • Title
  • Latitude
  • Longitude
  • Description
  • Street Address
  • Events*
  • People*
  • Categories
  • Featured Image


  • Title
  • Start
  • End
  • Description
  • Location*
  • People*
  • Categories
  • Featured Image

The items designated with the * are tied in via the ACF relationship field. This was something new to me but was super easy to set up. I turned on the ACF to Rest API plugin in addition to making sure these custom post types would appear in the WP REST API ‘show_in_rest’ => true.

Now I’ve got a decent interface for backend editing that makes adding connections between all these things as easy as clicking. We’ll refine things more going forward but it confirms some solid options that we can build on.
Animation showing association of one post with another.

The Data

With relationships we can either return the ID of the associated post or we can return the whole post object. Now at the REST endpoints (wp-json/wp/v2/event?_embed) for the various post types we’ll get JSON like this. I think this is going to be very nice for some of the related navigation construction.
JSON data structure showing the associated ACF post data.

1 I started to write him an email but it go very long and emails aren’t friendly to some of the formatting so I figured why not write a blog post.