In the last 14 years, WordPress has grown into a fully flexible content management system. However, with that growth comes the need to add non-standard endpoints to our workflow. Because these endpoints aren’t covered by the theme template hierarchy, wp-admin, or the default notes of the WordPress REST API, they have to be created from scratch.
In this article, I will cover common strategies for dealing with these situations such as adding custom rewrite rules, custom REST API endpoints to give an idea of the variety of methods that exist. Then I will look at how you can use the “parse_request” action to hook into WordPress loading early and use your own system and use the Symfony HTTP foundation as a practical example of how to modernize your WordPress-powered application.
A Look At The Traditional Solutions
Adding a custom endpoint is something we do when we need to route a very specific request to a specific code to process it. This might be for processing settings fields in the admin, responding to webhooks, showing user interface screens, or handling requests that result in file, for example, CSV, downloads.
In the past we had a few primary options that I want to discuss briefly, one at a time:
- At an early hook, such as init, check the super globals $_GET or $_POST for certain conditions and if they pass, running our code.
- Use a custom rewrite rule
- Use admin-ajax.php
- Adding a custom REST API endpoint
Hooking in at init or an early hook is pretty easy to do. It’s not a great solution, but it works. It’s not good because it’s not a standard and every implementation becomes ad-hoc and relies on everyone following conventions established per implementation.
Using admin-ajax.php became standard because we’ve been encouraged to copy the conventions of core development in our own plugins in our own sites. Without a proper API to use this was the option instead. It’s not a great system for all of the reasons I gave for using any other early action, but it’s also less efficient.
Because these solutions are strongly coupled to the WordPress Plugins API and the PHP superglobals, they are hard to write unit tests for. A well-executed, totally functional system using one of these two solutions is often so strongly tied to the current HTTP request that when we need to do run the same logic in a different scenario we can’t and are forced to use a copy-paste solution.
Adding custom rewrite rules is probably the most “correct” solution. But, working with the WordPress rewrites API is a quantifiably terrible developer experience. In a recent survey of WordPress developers. In my plugin Caldera Forms, we use a few custom rewrite rules to create an API for responding to requests to submit forms. It was a sensible decision at the time and it works unless the end-user server is really poorly setup, which of course happens.
That said, it’s not just a pain to setup, the Rewrites API is not particularly declarative or object-oriented. We don’t tell the API what fields we want to allow, and we don’t get any type of object representing the current HTTP request. Having an object for the current HTTP request, with defined fields is really useful. It makes it very easy to write unit tests that have a mock of that object. It also makes it easy to separate each part of the request/ process/ response cycle into different code. That allows us to connect the processing part of our code, totally decoupled from the incoming HTTP test to a unit test, a WP-CLI command or some other bit of PHP code with ease.
The WordPress REST API is architected this way. We have a WP_REST_Request object, that we use in our callback class that processes the request, and then returns a WP_REST_Response object. This architecture encourages and lends itself to testable, object-oriented code in a way that no other part of WordPress does.
So, when it’s the right context, I recommend using a custom endpoint of the WordPress REST API. I’ve written a ton about that, but it’s not all requests and response are RESTful or should be.
Using The HTTP Foundation Component
I miss having that WP_REST_Request object when I’m working with situations that don’t make sense to use custom REST API endpoints with. This is compounded by the fact that I’m pretty used to having that type of object available to me from working with other PHP frameworks.
For a WordPress project I’m working on I have a few types of requests that need custom processing that are not well suited for REST API endpoints. The incoming request isn’t JSON and I need to respond with either HTML or redirects. I was annoyed about my options and decided to try the Symfony HTTP Foundation Component to represent the incoming HTTP request.
This component is used as the basis of how most PHP frameworks interact with HTTP requests. It provides an object-oriented abstraction that is easy to work with and can be mocked for testing.
For the rest of this article, I’m going to give you a brief introduction to using it in a WordPress context. You might be tempted to call this over-engineering for a few endpoints. But, I hope you will see the benefits and that this learning experience will get you started in seeing how you can use modern PHP tools in your WordPress sites.
Installing And Choosing A Version
You can install HTTP Foundation using composer using the command:
composer require symfony/http-foundation
Symfony development keeps up to date with PHP development, so the latest version of HTTP Foundation now requires PHP 7.1, which in the WordPress world is not often available. For custom development, you should be using PHP7, but if you are releasing a plugin or theme, you will have to support older versions of PHP. This is especially true if you want to distribute on WordPress.org, which does not currently allow code using PHP7 syntax in the plugins directory.
You can always use an older version to support older PHP versions. In your composer.json file, specify version 3.2.8 of the component for PHP 5.6 support or version for 2.8.20 PHP 5.3.
The Request Object
In callbacks for WordPress REST API routes, we take a WP_REST_Request object and create a WP_REST_Response object. Hopefully, these callback functions act to work with other classes that handle the actual business logic of the application. This allows us to have an MVC-like system.
We get a system like this where “Business Logic” is a generic for CRUD classes or some other system that we want to call with a REST API Request. The good thing about this architecture is that we can have multiple entry points to that box.
Having decoupled CRUD and other business logic is great, but we still need a way to deal with requests that are not from the command line, are not RESTful or don’t make sense to be handled in the theme. Also, once we start to solve this problem, themes become optional. For example, what if we want to have one endpoint or set of endpoints that creates the HTML for single page JavaScript app powered by the WordPress REST API?
This is why I like using the Request and Response objects of the HTTP Foundation. They give us the object-oriented way of dealing with HTTP requests that we’re missing in many of those situations. Let’s take a look at each one.
The Request object is an abstraction over the current HTTP request and gives us a way to access GET or POST variables, as well as other important information like files, session, and cookie data. The Request object can be created manually, which is good for tests, but in normal use we can automatically populate it to match the current request, using the createFromGlobals method.
<?php use Symfony\Component\HttpFoundation\Request; $request = Request::createFromGlobals();
Now we can access a $_GET variable using the get() method, like this:
<?php use Symfony\Component\HttpFoundation\Request; $request = Request::createFromGlobals(); $priceOption = $request->get( 'price-option', 0 );
Notice that the second argument for this method is the default to use if that index of $_GET isn’t set. There are many similar methods for working with the other types of data in the request. All of which are wrappers around the very flexible and useful ParameterBag class, which is an implementation of the repository pattern.
In responding to a GET request, this would allow any GET variable to be used. This is not always ideal, as we might want to whitelist only specific GET variables and make sure they had sanitized values.
Here is an example where an array of accepted GET variables, with a default and sanitization callback — much like how it works in WordPress REST API requests is used to create a ParameterBag and then that is used in the Request object:
<?php use Symfony\Component\HttpFoundation\Request; use \Symfony\Component\HttpFoundation\ParameterBag; //Setup an array of accepted args with default and sanitize callback $vars = [ 'product_id' => [ 'default' => '1', 'sanitize' => 'absint', ], 'price' => [ 'sanitize' => 'edd_sanitize_amount', 'default' => '0.00' ] ]; //Prepare args from $_GET $prepared = []; foreach( $vars as $var => $args ){ if ( isset ( $_GET[ $var ] ) ) { $prepared[ $var ] = call_user_func( $args[ 'sanitize' ], $_GET[ $var ] ); }else{ $prepared[ $var ] = $args[ 'default' ]; } } //Create a parameter bag $getBag = new ParameterBag( $prepared ); //Create request from globals $request = Request::createFromGlobals(); //Change out default GET bag for ours $request->query = $getBag;
Of course, you’d only want to run this when responding to a specific request. Before we can build that kind of conditional logic, we need to discuss where to run this code.
Finding An Entry Point
What I’m showing you here is the beginning of a router. I will not go that far, I’ll be showing how to check the incoming HTML request for certain conditions and if so, inject an HTTP Request object into another class, and then respond with a Response object.
I think that this type of system is useful for responding to very specific requests. You could use it as a replacement for WP_Rewrites in the front-end for very specific types of sites. At that point, I question why you’d use WordPress for your front-end vs a different system connected to WordPress for content via the REST API.
When creating a few custom endpoints, the action “parse_request” is a great choice. At this point in the WordPress bootstrapping process, we have WP object and not much else. For example, WP_Query has not been initialized and no query has run. This gives us enough to determine if one of our custom endpoints is needed, and respond, or let WordPress continue on with its default behavior.
The WP class gives us the current query vars has a variable called page name we can use:
<?php add_action( 'parse_request', function( \WP $wp ){ //Find page name if( isset( $wp->query_vars[ 'pagename' ] ) ){ $pagename = $wp->query_vars[ 'pagename' ]; }else{ return; } if( in_array( $pagename, [ 'app-login', 'app-post-login' ] ) ){ //handle our custom requests here } //Nothing matched? OK, let WordPress be WordPress }
This is an example from a site I’m working on where I have a custom page created for logging into a non-WordPress app that uses WordPress for account and user management. This only has two routes, each has their own controller class, though only one needs a Request object. The other one, for now, just shows the standard WordPress login form with a post-login redirect to the other route
<?php use Symfony\Component\HttpFoundation\Request; use \Symfony\Component\HttpFoundation\ParameterBag; add_action( 'parse_request', function( \WP $wp ){ //Find page name if( isset( $wp->query_vars[ 'pagename' ] ) ){ $pagename = $wp->query_vars[ 'pagename' ]; }else{ return; } if( in_array( $pagename, [ 'app-login', 'app-post-login' ] ) ){ //Create request from globals $request = Request::createFromGlobals(); switch ( $pagename ){ case 'app-login' : new LoginController(); break; case 'app-post-login' : $vars = [ '_wpnonce' => [ 'default' => 1, ], 'subscription_id' => [ 'sanitize' => 'absint', 'default' => 0 ] ]; $prepared = []; foreach( $vars as $var => $args ){ if ( isset ( $_GET[ $var ] ) ) { if( isset( $args[ 'sanitize' ] ) ){ $prepared[ $var ] = call_user_func( $args[ 'sanitize' ], $_GET[ $var ] ); }else{ $prepared[ $var ] = $_GET[ $var ]; } }else{ $prepared[ $var ] = $args[ 'default' ]; } } $getBag = new ParameterBag( $prepared ); $request->query = $getBag; $controller = new PostLoginController( $request ); return $controller->getResponse(); break; } } });
I don’t want to get too much into what is going on in these classes, that’s not the point. Instead, I want to show you a Controller interface I’d recommend using that provides a method that has to return an object of the Response class.
<?php use Symfony\Component\HttpFoundation\Request; use \Symfony\Component\HttpFoundation\Response; interface makeResponse { /** * Get Response object * * @return Response */ public function getResponse() : Response; }
Using this for all of our controllers forces a pattern on our controllers that is good. If you’re looking at the code that I’ve done so far for my mini-router, you’ve likely seen that the one closure I’m working with is getting messy quickly. It would be better to come up with a dynamic system to call the right controller and sanitize the right parameters. But that’s getting into a full-fledged router, and I’d probably use a micro framework such and Silex or Lumen for that like I explained in a past article for Torque because we’d very quickly getting into re-inventing what has been done very well by many PHP MVC frameworks.
But, in a simple system with a few controllers, if they all implemented this interface, we could use the different types of Response objects to handle redirect response, file download responses and even outputting HTML responses.
I want to show you an example of each. But first, here is an abstract controller class each one will extend to provide a way to inject the Request object:
<?php use Symfony\Component\HttpFoundation\Request; use \Symfony\Component\HttpFoundation\Response; abstract class Controller implements makeResponse{ /** * @var Request */ protected $request; public function __construct( Request $request ) { $this->request = $request; } }
Now, here are my three controllers. They handle redirecting after login to an HTML page, that HTML page and image downloads:
<?php use Symfony\Component\HttpFoundation\Request; use \Symfony\Component\HttpFoundation\Response; use \Symfony\Component\HttpFoundation\RedirectResponse; use \Symfony\Component\HttpFoundation\BinaryFileResponse; use \Symfony\Component\HttpFoundation\ResponseHeaderBag; class PostLoginController extends Controller { /** @inheritdoc */ public function getResponse() : Response { $url = add_query_arg( 'token', $this->request->get( 'token', 0 ), home_url( 'app' ) ); return new \Symfony\Component\HttpFoundation\RedirectResponse( esc_url_raw( $url ) ); } } class AppHtmLController extends Controller { /** @inheritdoc */ public function getResponse() : Response { $html = include_once '/path/to/html.html'; return new Response( $html, 200, [ 'content-type' => 'text/html' ]); } } class ImageDownload extends Controller { /** * Get image as file contents * * @return string */ protected function getImage() { $image = get_attached_file( $this->request->get( 'id' ) ); return file_get_contents( $image ); } /** @inheritdoc */ public function getResponse() : Response { $response = new Response( $this->getImage() ); $response = new BinaryFileResponse( $this->getImage() ); $response->setContentDisposition( \Symfony\Component\HttpFoundation\ResponseHeaderBag::DISPOSITION_ATTACHMENT ); return $response; } }
How Far Do You Want To Take This?
The point of this article wasn’t to re-create WordPress routing using modern OOP PHP, though I am often tempted to. The point was to introduce you to a great tool — the HTTP Foundation Component and encourage you to explore its documentation. Also, once you are familiar with this component, you will be well equipped to work with most PHP frameworks.
That last point is important. I love WordPress, but it’s not the best tool for every job. As a PHP and JavaScript developer, I’ve got tons of great tools to use with or in place of WordPress and I want to take advantage of them all.
How much you use it in your applications, plugins, and sites should be dictated by your needs. But I encourage you to learn this stuff and make use of it.
No Comments