Remove a model’s Global Scopes in Laravel Nova

I am working on a Laravel project that has moderated reviews. Most of the time we only deal with reviews that have been approved, so the Review model has an ApprovedScope Global Scope to only show approved review, but staff members need to see un-approved reviews in our Nova admin so that we can approve, (or reject), reviews. Removing a Global Scope from a model for all of Nova is trickier than it appears at first glance, but there’s at least one way to do it, and maybe more.

Things that do not work

Call withoutGlobalScopes() in the Nova::serving() callback

Googling “remove global scope nova” turns up a post on Medium called Add or remove global scopes in Nova. Sounds promising! It claims we can remove global scopes by adding this to the boot method of the `NovaServiceProvider:

class NovaServiceProvider extends NovaApplicationServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        Nova::serving(function () {
            \App\Rating::withoutGlobalScopes();
        });
    }
}

Cool! But it doesn’t work. When adding a global scope to a model using the model’s boot method like the Laravel documentation shows, the model’s boot method is called after the Nova::serving() callback. So in this situation we remove nothing from the Rating, then add a global scope later when the model is booted.

Call withoutGlobalScopes() in the model’s booted event

If a model has a global scope added during its boot then we should be able to remove it during the ‘booted’ event – or at least that’s what I thought, so I tried this in NovaServiceProvider::boot :

Nova::serving(function() {
    Event::listen('eloquent.booted: App\Rating', function($rating) {
        Rating::withoutGlobalScopes();
    });
}); 

No dice. I’m not certain why this doesn’t work, but some XDebug spelunking shows me that:

  1. There’s no withoutGlobalScopes() method on Laravel models, but when the model gets a method call that it doesn’t recognize it passes it to the model’s Builder instance.
  2. The Builder does have a withoutGlobalScopes method, so we don’t get an InvalidMethodException. In the Builder’s withoutGlobalScopes call the ApprovedScope is removed from the Builder’s list of global scopes.
  3. But the Global Scope is still applied to the query. My best guess is that a different query builder is used to actually generate results, or that later, when the models are about to be retrieved from the DB, the model re-passes the list of Global Scopes to the builder – and since we haven’t removed the Global Scope from the model it gets re-added to the Query Builder. If anyone knows what’s actually going on here I would love to know in the comments or on Twitter.

Something that does work

We need to remove the Global Scope after the model has booted and prevent it from being passed or re-passed to the Query Builder. How about a Model::withoutGlobalScopes method? The model’s addGlobalScope method comes from the Illuminate\Database\Eloquent\Concerns\HasGlobalScopes trait, and stores the global scopes in a static::$globalScopes[static::class] array. Creating our own HasRemovableGlobalScopes trait, with withoutGlobalScope and withoutGlobalScopes methods that mirror the signature of the Illuminate\Database\Eloquent\Builder withoutGlobalScope and withoutGlobalScopes methods can solve the problem, (also available as a Gist):

<?php

namespace App\Concerns;

use Closure;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Arr;

trait HasRemovableGlobalScopes {

	/**
	 * @param  \Illuminate\Database\Eloquent\Scope|string  $scope
	 */
	public static function withoutGlobalScope( $scope )
	{
		if (is_string($scope) && is_array(static::$globalScopes[static::class])) {
			Arr::forget(static::$globalScopes[static::class], $scope);
		} elseif ($scope instanceof Closure) {
			Arr::forget(static::$globalScopes[static::class], spl_object_hash($scope));
		} elseif ($scope instanceof Scope) {
			Arr::forget(static::$globalScopes[static::class], get_class($scope));
		}
	}

	/**
	 * @param \Illuminate\Database\Eloquent\Scope[]|string[] $scopes
	 */
	public static function withoutGlobalScopes( array $scopes = [])
	{
		if(empty($scopes)) {
			static::$globalScopes = [];
		} else {
			foreach($scopes as $scope) {
				static::withoutGlobalScope($scope);
			}
		}
	}
}

The withoutGlobalScope method mirrors the HasGlobalScopes::addGlobalScope method to remove a single global scope, and the withoutGlobalScopes method can accept an array of global scopes to remove or be called with no parameters to remove all global scopes, (the same as Builder::withoutGlobalScopes).

A drawback?

The fact that we have to do this to remove global scopes for Nova seems to be an oversight. I’m hopeful that withoutGlobalScope/withoutGlobalScopes methods will be added to future versions of Laravel. If that happens there will be a method name collision between the HasRemovableGlobalScopes trait’s methods and the first-party ones, so read the release notes if you’re going to use this method.

Something else that might work

After writing the HasRemovableGlobalScopes trait I realized it should be possible to create an additional global scope that undoes my original global scope. In this case something like this might work:

Nova::serving(function() {
    Event::listen('eloquent.booted: App\Rating', function($rating) {
        Rating::addGlobalScope(new AllRatingsScope);
    });
}); 

Where AllRatingsScope says to include all ratings. I haven’t tried this so don’t know what happens when there are two global scopes that specify opposite conditions. You might get no results, or the scope that’s applied last might win. Assuming that the scope applied last wins it’s still important to use the eloquent.booted event to make sure the “undo” scope is added after the original scope.

All together now

I’m sticking with the HasRemovableGlobalScopes trait. I feel like actually removing the Global Scope is more logical than adding another scope to undo what the first one does. Using the HasRemovableGlobalScopes trait, this is what my whole system looks like:

Apply the original global scope to the Rating, (or wherever you need a global scope), and use the HasRemovableGlobalScopes trait:

class Rating extends Model
{
	use HasRemovableGlobalScopes;

	// ... 

	protected static function boot()
	{
		parent::boot();
		static::addGlobalScope(new ApprovedScope);
	}

	// ...
}

Set the event listener in the NovaServiceProvider’s boot method to remove the global scope for Nova:

class NovaServiceProvider extends NovaApplicationServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
	    Nova::serving(function() {

		    Event::listen('eloquent.booted: App\Rating', function($rating) {
			    Log::info('rating booted ' . request()->url());
		    	Rating::withoutGlobalScopes();
	        });
	    });

	    parent::boot();
    }
}

It works well for me. A possible enhancement would be to have my own App\Model class that extends Laravel’s Model and uses HasRemovableGlobalScopes, then extend my other models from that. At the moment I don’t have my own App\Model, and don’t have a lot of places where I need to share between all my models, but if I find I’m sprinkling too many use HasRemovableGlobalScopes lines around my code base I’ll make the change.

Changing the Name and Path of the Active WordPress Theme Without Breaking Theme Settings

After changing a brand name, but little else, on a WordPress site we can end up with a WordPress theme called “oldbrand” on a site called “New Brand.” This leads to weird things like the brand’s logo being at the URL newbrand.com/wp-content/themes/oldbrand/img/newbrand-logo.png. Oops. SEOs must cry a little when they see something like that. This is a situation one of my clients ended up in after a name change, but it ends now.

We can’t simply change the theme directory from themes/oldbrand to themes/newbrand and update the active theme in the Appearance section of the wp-admin because theme-related settings are tied to the theme directory in the database. This includes things like widget placement; a different theme probably has very different widget areas so widget placement should be stored per-theme. The same goes for any theme settings & customizations. To get around this we need to update the database so WordPress thinks that the new theme name is the theme name we’ve always been using.

A word of caution: back up your database before trying this. We’re going to run raw queries. If something breaks you may end up in an in-between limbo, and restoring from backup is the quickest way out.

After diving through the database of a few sites this morning these queries make it possible to rename the theme directory, and optionally rename the theme in style.css:

# Update the main theme options
UPDATE wp_options SET option_value='new-theme-directory' WHERE option_name='template';
UPDATE wp_options SET option_value='new-theme-directory' WHERE option_name='stylesheet';
UPDATE wp_options SET option_name='theme_mods_new-theme-directory' WHERE option_name='theme_mods_old-theme-directory';

# If also updating the Theme Name in the theme's style.css
UPDATE wp_options SET option_value='New Theme Name' WHERE option_name='current_theme';  

# If any posts reference assets in the theme such as images or logos.
UPDATE wp_posts SET post_content=REPLACE(post_content, 'themes/old-theme-directory', 'themes/new-theme-directory' );
UPDATE wp_postmeta SET meta_value=REPLACE(meta_value, 'themes/old-theme-directory', 'themes/new-theme-directory');

# If WordFence is used
UPDATE wp_wfConfig SET val=REPLACE(val, 'themes/old-theme-directory', 'themes/new-theme-directory');

# Delete a couple of transients that store references to the old theme directory.
DELETE FROM wp_options WHERE option_value='_site_transient_theme_roots';
DELETE FROM wp_options WHERE option_value='_site_transient_update_themes';

Protips:

  • Try to run these queries at nearly the same time as the theme directory is renamed. Bonus points for making a script that does it all nearly instantly.
  • If an installation stores transients somewhere other than the DB, then clearing them in the DB won’t work, (clearing the transients may not be strictly required).
  • The sample code uses the default wp_ table prefix. When working on a site with a different prefix, use that.

Hopefully this helps someone finish their Googling session and get on with a rename!

Preview Laravel Password Reset and Verification E-mails in a Browser

I’m building something on Laravel, using the built-in Auth with 5.7’s new E-mail Verification feature. Today’s task was to tweak the design of E-mail notifications a bit, so I wanted to preview them in a browser. I thought it would be easy, but the built-in E-mails use the Notification system, bypassing Mailable objects. Some Googling didn’t help much, but combining the Notifications documentation and this forum post got let me preview the messages. Hopefully this post can help someone else preview their messages much more quickly than I did today.

The concept is similar to the “Previewing Mailables” documentation: define a route to view your preview on, and return something that will render the message. The trick is that the MailMessage objects that the Notifications’ toMail() methods return don’t have a render() method like Mailable class does, so we need to find something else. Since the built-in Auth uses Markdown mail we can use the Markdown class. A Notifiable object, (the User object for the user who will get the notification), and a Notification are also needed.

For this to work you need to have the right Notifications published to your resources/views/vendor directory. This is done with the Artisan command php artisan vendor:publish --tag=laravel-notifications.

And set up the preview routes:


Route::get('/reset-notification', function () {

    // Get a user for demo purposes
    $user = App\User::find(1);

    // Make a Reset Notification object, (subclass of `Notification`)
    $resetNotification = new \Illuminate\Auth\Notifications\ResetPassword( 'some-random-string-this-will-be-much-longer-in-real-life' );

    // get the `MailMessage` object
    $mailMessage = $resetNotification->toMail($user);

    // get the `Markdown` object
    $markdown = new Illuminate\Mail\Markdown(view(), config('mail.markdown'));

    // Render the vendor.notifications.email view with data from the `MailMessage` object
    return $markdown->render('vendor.notifications.email', $mailMessage->toArray());
});


Route::get( '/verify-notification', function () {

    // Get a user for demo purposes
    $user = App\User::find(1);

    // Make a Verify Notification object, (subclass of `Notification`)
    $verifyNotification = new Illuminate\Auth\Notifications\VerifyEmail();

    // get the `MailMessage` object
    $mailMessage = $verifyNotification->toMail($user);

    // get the `Markdown` object
    $markdown = new Illuminate\Mail\Markdown(view(), config('mail.markdown'));

    // Render the vendor.notifications.email view with data from the `MailMessage` object
    return $markdown->render('vendor.notifications.email', $mailMessage->toArray());
});

Once the Notification is published and the routes created it is possible to preview the Password Reset and E-mail Verification E-mails in the browser.

Changing the Auto-Suggest Behaviour in WooCommerce

A request came in this week from a client with a WooCommerce store:

I just placed an order through our website and had an issue with the state. The customer lives in Oregon, so I typed “O” hoping Oregon would auto populate, but it didn’t. I was stuck having to scroll through all 50 states to find Oregon.

An animated Gif showing WooCommerce's default autosuggest behaviour on select boxes.
Confusing for most people.

I tried it, and it’s true. Typing “o” doesn’t narrow the list down much, and the way it does narrow it down doesn’t make much sense in this context: The autosuggest on the State field matches the search phrase anywhere in one of the options, so typing “o” gets you a list of all states with the letter “o” in the state name, ordered alphabetically by state name. Go ahead and try it out in a WooCommerce demo shop.

Out of the box WooCommerce uses SelectWoo, which is essentially Select2 with more accessibility. Select2’s default behaviour of matching the typed text to any part of an option makes sense in many places, especially when searching a store or for categories, but for selecting states, (and countries, although slightly less), it’s confusing.

Nothing’s filterable, but we can set a default

Select2 lets us set the callback function used for matching, so we can customize the matching behaviour, but there’s a problem, the settings for SelectWoo are set in WooCommerce’s country-select.js file, and there’s no way to filter them, so we can’t change the matching behaviour on initialization. It turns out that once a Select2/SelectWoo instance is initialized there’s no way to change the settings either.

When I got to here I thought we were out of luck – either I’d be creating a child theme and re-implementing the checkout, or we were going to live with SelectWoo’s default filtering behaviour. Luckily, I found one way to change the matching callback: It’s possible to set the default SelectWoo options callback before any instances are initialized. So that’s what we’re going to do.

Server-Side

First we need to add a bit of Javascript to our checkout page, so we enqueue a small script that’s dependent on the selectWoo script.


namespace JB\WooAutosuggest;



function enqueue_frontend_scripts() {
    
    if( function_exists('is_checkout') && is_checkout() ) {

        wp_enqueue_script( 'jb-was-checkout-autosuggest', plugins_url( '../assets/checkout-autosuggest.js', __FILE__), array('selectWoo') );

    }
}

add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\enqueue_frontend_scripts', 20 );

And that’s all the PHP we need! Because our solution changes the default matching behaviour we are careful to only enqueue this script on the Checkout page using WooCommerce’s is_checkout() function.

Client-Side

The enqueued Javascript file is pretty simple as well:

(function($) {
    // Based on custom matcher here: https://select2.org/searching#customizing-how-results-are-matched
    function start_matcher(params, data ) {
        
        // If there are no search terms, return all of the data
        if ($.trim(params.term) === '') {
          return data;
        }

        // Do not display the item if there is no 'text' property
        if (typeof data.text === 'undefined') {
          return null;
        }

        // `params.term` should be the term that is used for searching
        // `data.text` is the text that is displayed for the data object
        // Make sure to compare in the same case! 
        if ( data.text.toLowerCase().indexOf(params.term.toLowerCase() ) === 0 ) {
            return data;
        }
        // Return `null` if the term should not be displayed
        return null;
    }
    $.fn.select2.defaults.set('matcher', start_matcher );
})(jQuery);

There’s a closure so we don’t pollute the global namespace, then we define a new matcher function that matches the beginning of the phrase, and set it as the default matcher. This code runs as soon as it’s ready – it doesn’t wait for a DOMReady event, because by the time DOMReady fires the SelectWoo boxes have probably been initialized already.

Conclusion

I wish we didn’t have to set the defaults for the whole page, but that’s what we’re stuck with at the moment. We could apply different matching patterns to different pages by adding a few more conditions to the PHP block, and a bit more Javascript. This works to make the checkout page work as expected, though.

All the code is up on Github as a WordPress plugin. If it’s useful let me know!

An animated Gif showing WooCommerce's autosuggest matching the beginning of each option.
This makes more sense!

Adding Custom Fields to WooCommerce Products in 2018

Screenshot showing the Shipping section of WooCommerce's Product Data box.
Look, there’s a Shipping section!

This week a client asked to add estimated ship times to the single-product pages in WooCommerce. Ship times are sometimes different for different products, so adding it as a custom field to each product made sense. As a bonus, there’s a “Shipping” tab in the Product Data metabox that the field can go in. Some Googling led me to Tom McFarlin‘s “Adding Custom Fields to Simple Products with WooCommerce” tutorial on tuts+, which is pretty thorough, but out of date – the actions he’s are no longer in WooCommerce, so it’s time for a new version of that tutorial, my-style. There are three main tasks to accomplish: Add the field to the WordPress back-end, save whatever is in the field, and output the data on the Single Product page. We’ll look at each task separately. The completed plugin is up on GitHub if you want to follow along.

Add the Custom Field to WooCommerce in the WordPress Back-End

WooCommerce provides an incredible number of hooks and filters. We’re going to us woocommerce_product_options_shipping hook, because it runs in the shipping block, but is an equivalent hook for each Product Data tab. Look at the files in the woocommerce/includes/admin/meta-boxes/views directory to understand how the Product Data, (and all the WooCommerce Metaboxes), are created, and what hooks are available.

Now we know what hook we need, it’s time for some code:


function print_admin_field() {
    /**
     * @var \WC_Product
     * @see https://docs.woocommerce.com/wc-apidocs/source-class-WC_Meta_Box_Product_Data.html#42
     */
    global $product_object;

    if( $product_object->get_type() == 'simple' ) {
        $field = [
            'id'            => 'sif_ships_in',
            'label'         => __('Ships In', 'jb-sif'),
            'placeholder'   => __('example: 1-2 days', 'jb-sif'),
            'value'         => get_post_meta( $product_object->get_id(), '_sif_ships_in', true ), // Pre-fill any values that have already been saved.
            'description'   => __( 'Add a "Ships in X" message below the Product Meta on the single-product page.', 'jb-sif'),
            'desc_tip'      => true
        ];

        \woocommerce_wp_text_input( $field );
    }
}

add_action( 'woocommerce_product_options_shipping', 'JB\SIF\print_admin_field');

We make sure we have the $product_object variable available, (it’s set in the WC_Meta_Box_Product_Data class, linked with the @see directive), then we make sure we’re adding the field to a “Simple” product type. Then, instead of writing a bunch of HTML, we set some info in an array, and pass it to the woocommerce_wp_text_input() function, (with a backslash, because the plugin is namespaced). WooCommerce has several of these helper fields available that create a form fields and their associated markup so they fit nicely with the rest of the WooCommerce admin.

Notice we’re already getting a value with get_post_meta(). That’s because the same code gets used every time the field is displayed – so if there’s a value in the database we want to display it. This is all we need to properly display a field in the Shipping section of the Product Data. We could add a name attribute to the $field variable, but it defaults to the value of id, so I left it out. Setting desc_tip to true puts the description in the tooltip that appears when someone hovers the circle with the question mark beside the field.

The last line of the code block above hooks our function into the woocommerce_product_options_shipping action. Because the plugin has its own namespace we need to include the full name of the function in the add_action() call.

A screenshot of the new "Ships In" field in the WooCommerce admin.
Our new “Ships In” field, looking great.

Save Whatever is Entered in the Field

Again, a game of hooks figuring out how to save the data. Tom’s tutorial from last year used a woocommerce_process_product_meta hook which no longer exists. It looks like it’s been replaced by a woocommerce_process_product_meta_* hook for each product type. Since we’re only using a Simple product, we’ll use the woocommerce_process_product_meta_simple hook. Now we know which hook to use, some code:


function save( $post_id ) {

    // check nonce
    if( ! ( isset( $_POST['woocommerce_meta_nonce'], $_POST['sif_ships_in'] ) || wp_verify_nonce( sanitize_key( $_POST['woocommerce_meta_nonce'] ), 'woocommerce_save_data' ) ) ) {
        return false;
    }

    update_post_meta( $post_id, '_sif_ships_in', sanitize_text_field( $_POST['sif_ships_in'] ) );

}
add_action( 'woocommerce_process_product_meta_simple', 'JB\SIF\save' );

In the save() function, (which we can name “save” without collisions because we’re using namespaces), we check the field is part of the $_POST array, check the nonce set by WooCommerce, and save the contents of the field, without forgetting to sanitize the data first.

WooCommerce may already checks the nonce for us, but I am not 100% sure, so I added an extra check.

Output the Data on the Single Product Page

WooCommerce’s plethora of hooks make placing the output relatively simple. I chose to put it right below the “Add to Cart” button on the Single Product page, but if you look through WooCommerce’s templates/content-single-product.php file and the files in the templates/single-product directory you’ll find lots hooks to use – and of course you’re not restricted to the single-product page. If you want to add your output to each product in a list of products take a look in the templates/content-product.php file.


function print_output() {

    global $product;
    $ships_in = get_post_meta( $product->get_id(), '_sif_ships_in', true );


    if( !empty( $ships_in )): ?>
        
< ?php echo sprintf( __( 'Ships in %s.', 'jb-sif' ), $ships_in ); ?>
< ?php endif; } add_action( 'woocommerce_single_product_summary', 'JB\SIF\print_output', 45 );

Our print_output() function is pretty simple: Grab the post_meta, check it’s not empty, and output it mixed in with a “Ships In” string. It’s hooked into the woocommerce_single_product_summary action with a priority of 45, which means it appear after the Add to Cart button and after the Product Meta. WooCommerce’s template files are really good at explaining what functions are hooked to actions, and the priority they’re hooked at, which makes it easy to place things on a page without having to copy templates over to your theme.

A screenshot showing the output of the "Ships In" field on the single-product page.
Here you can see the output of the new “Ships In” field on the single-product page. The product meta was moved elsewhere.

Conclusion

Adding fields to the WooCommerce part of a WordPress admin area is much easier than adding a whole metabox. You can grab the completed plugin on Github. You’ll see there’s a bit more to the plugin: requirements are checked using Mark Jaquith’s method before embarking on a namespaced plugin with shorthand array syntax.

If this is useful let me know, and feel free to open tickets and send pull requests on Github.