How to execute a no-nonsense upgrade to PHP 7.4 on Ubuntu 16.04 LTS

Yeah, yeah. Ubuntu 16.04 LTS is getting pretty long in the tooth. Long-term support ends in less than a year.

But if you’re anything like me (I’m sorry), you’re managing multiple VPSes that are, at the moment, still running it. And now WordPress is giving all of your clients scary warnings about needing to upgrade their version of PHP. What to do?

I’ve distilled the process down to 11 lines that you can just copy-paste straight into the command line. It’s not entirely hands-off; there are a few steps where you’ll be asked to confirm whether you want to keep your existing configuration files (YES!) and such. And — very important — you’ll want to review the set of PHP-related packages I’ve got listed here to make sure they’re ones you need, and that they’re all the ones you need. If you’re not sure whether or not there are others you may want, I suggest running apt update and then apt-cache search php7.4 and reviewing the list of results before proceeding.

Now then… here we go. I’ll break it all down after the code sample.

CAVEAT EMPTOR: I’ve just run this series of commands on three servers and it seemed to work fine, but this code is provided AS IS and you’re on your own if anything gets screwed up.

This assumes you’re already in sudo mode. If not, start with a sudo -s and FEEL THE POWER.

apt update
apt -y install software-properties-common
add-apt-repository -y ppa:ondrej/php
add-apt-repository -y ppa:ondrej/apache2
apt update
apt -y dist-upgrade
apt -y autoremove
apt -y install php7.4 libapache2-mod-php7.4 php7.4-mysql php-imagick php7.4-cgi php7.4-cli php7.4-common php7.4-curl php7.4-gd php7.4-json php7.4-mbstring php7.4-opcache php7.4-soap php7.4-xml
a2dismod php7.0
a2enmod php7.4
service apache2 restart

OK, what are we doing here? Let’s break it down.

apt update

Updating our package cache. Gotta do this first, always.

apt -y install software-properties-common

You may already have this installed. I’m not entirely sure what it’s for but the other articles I read had me doing that before the next steps so who am I to argue?

add-apt-repository -y ppa:ondrej/php
add-apt-repository -y ppa:ondrej/apache2

We are adding external package repositories created by Ond?ej Surý that allow versions of Ubuntu Linux to install newer versions of PHP than what comes with the standard Canonical set.

apt update
apt -y dist-upgrade
apt -y autoremove

Gotta do this again, since we’ve added new repositories. We’re doing a full-blown update of any outdated packages in the OS, and using the -y switch means we’re not going to be asked to manually confirm before proceeding. Be careful!

apt -y install php7.4 libapache2-mod-php7.4 php7.4-mysql php-imagick php7.4-cgi php7.4-cli php7.4-common php7.4-curl php7.4-gd php7.4-json php7.4-mbstring php7.4-opcache php7.4-soap php7.4-xml

This is the big one. We’re installing PHP 7.4 as well as a bunch of related packages we probably need. If you don’t know what all of these do, I encourage you to research them. You may not need them all. You may need others not included here. But these seem to do the trick for a typical WordPress setup.

a2dismod php7.0
a2enmod php7.4

Here we’re telling Apache to stop using PHP 7.0 and to use PHP 7.4 instead. This assumes you’re currently running PHP 7.0, which would be the case if you’re still on the default Ubuntu 16.04 LTS packages.

service apache2 restart

Let’s restart Apache and get that PHP 7.4 goodness! Hopefully everything works! But I suppose we should also be forward-thinking. This command is deprecated and I believe removed completely in Ubuntu 20.04, so you could use the more modern (but to my eye, decidedly less friendly) systemctl restart apache2 instead.

Postscript

One more thing… along the way you might have updated some packages that recommend a restart. If that’s the case, throw in one last command for fun:

reboot

Obviously if your server gets a ton of traffic you may not want to reboot in the middle of the day. But then you shouldn’t have been doing any of this in the middle of the day. The Digital Ocean VPSes I use typically reboot in less than 10 seconds, so I am never too hesitant to reboot at any time. Some of the other commands above, however, may shut down Apache or MySQL for a longer period (probably not more than a minute or two).

Post-postscript

This should also work more or less the same for any other version of Ubuntu you’re trying to keep fresh past its sell-by date. The main thing you might need to look at is the a2dismod php7.0 line. You’re probably running a different version of PHP. You can use php -v to see which version you’re running, and you can run ls /etc/php to see which version(s) you have installed.

A quick fix for the impossibility of building new menus on a WordPress site with a large number of pages

I’m working on a theme migration for a WordPress site that as a lot of pages. A lot of pages. 451 pages.

The old version of the site was using page menus, not custom menus, but it was relying on a constellation of abandoned plugins, and that approach just won’t work for the new theme. We need to build custom menus.

The problem is, building a new menu on a WordPress site that has 451 pages is a daunting task. Granted, only 39 of these pages need to go into the main navigation. (A lot of them really should probably be deleted, but that’s the client’s call, not mine.) So, I’m glad I don’t have to wrangle over 400 pages into a menu, but even 39 can be difficult given the shoddy interface WordPress provides for finding pages to add to your menu.

There are three tabs: Most Recent, View All, and Search. Most Recent is useless unless you’re just adding recently created pages to an existing menu. View All is useless because it’s paginated, inside this tiny box, and it lists the pages in an entirely inscrutable order. (OK, it’s not inscrutable. I can scrute it. But just because I understand the logic of how they’re ordered doesn’t mean that order is easy to work with.) And lastly we have Search which seems like the saving grace. But it’s actually the most maddening of the three because of two things:

  1. WordPress search sucks. It doesn’t give extra weight to page titles; it searches the full content. So even if I type in the exact title of the page I want, it’s usually not first in the list. And that’s a problem because…

  2. It only returns 10 results. That’s it. Ten. No lazy loading of more, no pagination, nothing. If your page doesn’t come back in the top 10 results, it may as well not exist.

I’d really love to rebuild the internal WordPress search engine to be smarter about weighting titles. Well, OK, no I wouldn’t. That would be a project I would not enjoy. But I would like for it to be done by someone.

Since that’s not likely to happen, at least there’s a way we can modify the search results to change the number of pages returned. I found the solution, as I often do, on StackExchange. But I dislike a few of the answer’s coding conventions, and I wanted to make one specific change for myself, so here’s my version. (You may not like using closures, especially in a scenario like this because it prevents you, or anyone else, from being able to remove this logic elsewhere. If I were writing a public plugin for this, I’d definitely make it a named function, but this should be fine for a custom theme.)

add_action('pre_get_posts', function($query) {
  if (is_admin()) {
    if (isset($_POST['action']) && $_POST['action'] == 'menu-quick-search' && isset($_POST['menu-settings-column-nonce'])) {
      if (is_a($query->query_vars['walker'], 'Walker_Nav_Menu_Checklist')) {
        $query->query_vars['posts_per_page'] = -1;
      }
    }
  }
  return $query;
}, 10, 2);

The important difference between the StackExchange sample and my code is that I changed the results from 30 to -1 which, in the WordPress universe, equals . Fun!

The standard warning is that setting posts_per_page to -1 is inherently risky because it could cause performance problems. But in my testing of this change, it does not appear to be an issue on this site with 451 pages, so I’m guessing it won’t be for you, either.

Now, instead of getting back a paltry ten results, you’ll get all the results that match your search. And the exact page title you typed in should be in there, somewhere.

Add arbitrary product data to order items in WooCommerce

This seems to be way more convoluted than it needs to be, but I’m not sure how much of that is that it’s actually convoluted, how much is that Woo’s documentation sucks, and how much is that everyone else’s tutorial on it is tl;dr.

Anyway… I just wanted to do something fairly simple. I want to have each product’s short description get sent into the order data. This is a specific use case with a client who’s syncing data over the REST API with an external system, and we’re shoehorning data into the short description that maybe could go somewhere else. The point is, use your imagination as to how this might be useful to you.

I’m stripping out a lot of the other details. All I want is a way to a) add the data to the item in the cart, and b) carry that data over into the order item meta data in the database. You may need or want more, but this will get you started.

// Add custom order item meta data to cart
add_filter('woocommerce_add_cart_item_data', function($cart_item, $product_id) {
  if (!isset($cart_item['short_description'])) {
    if ($product = wc_get_product($product_id)) {
      $cart_item['short_description'] = $product->get_short_description();
    }
  }
  return $cart_item;
}, 10, 2);

// Add custom order item data from the cart into the order
add_action('woocommerce_checkout_create_order_line_item', function($item, $cart_item_key, $values, $order) {
  if  (isset($values['short_description'])) {
    $item->add_meta_data('Short Description', $values['short_description'], true);
  }
}, 10, 4);

This is a major distillation of stuff I found in these two tutorials: How to Add a Customizable Field to a WooCommerce Product and Add Custom Cart Item Data in WooCommerce.

How to sort empty values last in WordPress

For the past several days I’ve been hammering my head against a conundrum: how to get WordPress to sort a set of posts in ascending order, but with empty values at the end of the list instead of the beginning.

This seems like it should be a simple option in the query. But MySQL doesn’t offer a straightforward way to do this. There are some fairly simple MySQL tricks that will accomplish it, but there’s no way to apply those tricks within the context of WP_Query because they require manipulating either the SELECT or ORDER BY portions of the SQL query in ways WP_Query doesn’t allow. (I mean, you can write custom SQL for WP_Query, but if you’re trying to alter the output of the main query, good luck.)

I tried everything I could possibly think of yesterday with the pre_get_posts hook, but it all went nowhere, other than discovering a very weird quirk of MySQL that I don’t fully understand and won’t bother explaining here.

Sleep on it

I woke up this morning with an idea! I resigned myself to the fact that this ordering can’t happen before the query runs, but I should be able to write a pretty simple function to do it after the query has run.

Bear one key thing in mind: This is not going to work properly with paginated results. I mean, it’ll sort of work. The empty values will get sorted to the end of the list, but they’ll stay on the same “page” they were on before the query was run. In other words, they’ll be sorted to the bottom of page one, not of the last page. Anyway… consider this most useful in cases where you’re setting posts_per_page to -1 or some arbitrarily large number (e.g. 999).

The function

This simple (and highly compact) function accepts a field name (and a boolean for whether or not it’s a custom field [meta data]), then takes the array of posts in the main query ($wp_query), splits them into two separate arrays — one with the non-empty values for your selected field, one with the empty values — and then merges those arrays back together, with all of the non-empty values first. (Other than shifting empties to the back, it retains the same post order from the original query.)

function sort_empty_last($field, $is_meta=false) {
  global $wp_query;
  if (!$wp_query->is_main_query()) { return; }
  $not_empty = $empty = array();
  foreach ((array)$wp_query->posts as $post) {
    $field_value = !empty($is_meta) ? get_post_meta($post->ID, $field) : $post->{$field};
    if (empty(implode((array)$field_value))) { $empty[] = $post; }
    else { $not_empty[] = $post; }
  }
  $wp_query->posts = array_merge($not_empty,$empty);
}

Calling the function

As I said, this function is designed to work directly on the main query. You just need to call the function right before if (have_posts()) in any archive template where you want it to apply. Because of the way it works — especially the posts_per_page consideration — I thought calling it directly in the template was the most clear-cut way to work with it. Here’s an example of the first few lines of a really basic archive template that uses it, looking for a custom field (meta data) called deadline:

<?php

get_header();

sort_empty_last('deadline', true);

if (have_posts()) {

How to modify WooCommerce to prevent users from selecting UPS shipping for P.O. Box addresses

Anyone who’s dealt with e-commerce in any capacity probably knows that UPS won’t deliver to P.O. boxes. Well, technically they can’t deliver to P.O. boxes. And apparently they’ll forward packages on to the box owner’s physical address, but they charge a big extra fee to do it. So, you want to avoid it.

Unfortunately, WooCommerce and its UPS Shipping add-on do not account for this, and will accept UPS orders to P.O. box addresses. Not good.

The official WooCommerce developer documentation has an article on how to block P.O. box shipping, but it applies to all shippers. Not what we want.

Also, I’m not sure if the documentation is outdated or what, but their code sample didn’t work for me with the latest version (3.4.3) of WooCommerce, because of the wc_add_notice() function.

I’ve modified the original code to add a check for UPS shipping, and also to use the $errors variable. (I also considered removing the global $woocommerce; line since it seems unnecessary, but I didn’t take the time to test whether or not it’s definitely safe to remove, so I left it in.)

add_action('woocommerce_after_checkout_validation', function($data, $errors) {
  global $woocommerce;
  if (isset($data['shipping_method'][0]) && strpos($data['shipping_method'][0], 'ups') === 0) {
    $address1 = (isset($data['shipping_address_1'])) ? $data['shipping_address_1'] : $data['billing_address_1'];
    $address2 = (isset($data['shipping_address_2'])) ? $data['shipping_address_2'] : $data['billing_address_2'];

    $replace = array(” “, “.”, “,”);
    $address1 = strtolower(str_replace($replace, '', $address1));
    $address2 = strtolower(str_replace($replace, '', $address2));

    if (strstr($address1, 'pobox') || strstr($address2, 'pobox')) {
      $errors->add('shipping', __('Sorry, UPS cannot deliver to P.O. boxes. Please enter a street address or choose another shipping method.' . $datadump, 'woocommerce'));
    }
  }
}, 10, 2);

Important notes:

1. This code may not immediately work for you; I believe the 'ups' string in the conditional line may vary depending on your Shipping Classes settings, so you may need to investigate exactly what values are returned in $data['shipping_method']. Since this code is fired off by an AJAX call, it can be difficult to debug. I was able to crudely debug it by commenting out the conditional, then appending print_r($data) to the error string.

2. This is using an anonymous function, so it won’t work in PHP versions below 5.3. But you’re not using a PHP version that old, are you? ;)

3. The original version checked the address line 1 and the postcode field, rather than address lines 1 and 2. I’ve United States-ified my code because that’s what I needed. If you’re part of the other 95% of the world, you may need to add that back in, with appropriate adjustments to the nested conditional. (I’m not really sure if this issue is as UPS-specific outside the US, so my modifications may not be relevant.)