How to Create a PSR-4 Structure WordPress Plugin

Sample Fake Plugin to Demonstrate Autoloading

What is PSR-4?

Using a PSR-4 compatible structure for your WordPress plugin promotes better organization and readability of code, making it easier to manage and maintain. Since there is a typical file-to-folder structure match-up, navigation within the code becomes more intuitive. This standardized autoloading technique eliminates guesswork when accessing classes, allowing familiarity if another codebase shares the same structure.

In this first example, let’s assume I have a PHP namespace of DLXPlugins\APlus. Here’s a sample folder structure for the plugin:

.
└── aplus/
    ├── aplus.php
    └── includes/
        └── Admin/
            └── Notices.phpCode language: plaintext (plaintext)

I can reference a class file, which will look in an Admin folder for a file named Notices.php.

<?php
namespace DLXPlugins\APlus;

use DLXPlugins\APlus\Admin\Notices;Code language: PHP (php)

The class structure for Notices.php starts as:

<?php
/**
 * Admin notices class.
 *
 * @package APlus
 */

namespace DLXPlugins\APlus\Admin;

if ( ! defined( 'ABSPATH' ) ) {
	die( 'No direct access.' );
}

/**
 * Network Admin notices class.
 */
class Notices {Code language: PHP (php)

Since the folder and class structure are the same, there is no longer any guesswork as to what class or file you are interacting with.

Let’s get started with setting up PSR-4 for a WordPress plugin, aiming to meet strict WordPress coding standards.

Table of Contents

Some Prerequisites

You will need the following set up and configured for this tutorial. Each of these topics is somewhat complex, so for brevity, I will include the requirements upfront.

You will need Composer installed

GetComposer Website

We’ll be using Composer to set up the autoloader.

You will need a local development environment

Local by WP Engine – Development Environment

This tutorial assumes you have a local environment set up and are ready to go. I will be covering things from a Mac perspective. The following downloads support both Mac and Windows environments.

I recommend (for Mac and Windows) the following local environments:

  • MAMP Pro – a very easy local environment that is also very flexible and powerful.
  • Local – the most straightforward setup for a local environment, but not as configurable as MAMP Pro.

You will need Node installed

Node will be used to add any NPM scripts we’ll need. If you need to install Node, you can follow the installation instructions on the Node.js website.

You will need WordPress Coding Standards Setup and Configured

Setting up WordPress Coding Standards is a rabbit hole of a task, which is why it won’t be covered here.

There are numerous tutorials on the topic, so I’ve linked to some below.

For both of the links mentioned above, you will need PHP_CodeSniffer (PHPCS) installed.

Initializing the Fictional Plugin

In the examples in this tutorial I’m using a fictitious plugin I’ve named Fauxpress Site Hygiene. In your local development environment, create a plugin folder named fauxpress-site-hygiene with a base file named fauxpress-site-hygiene.php

.
└── fauxpress-site-hygiene/
    └── fauxpress-site-hygiene.phpCode language: AsciiDoc (asciidoc)

Within fauxpress-site-hygiene, we can set the plugin header:

<?php
/**
 * Plugin Name: Fauxpress Site Hygiene
 * Plugin URI: https://example.com
 * Description: A fake plugin demonstrating PSR-4 autoloading.
 * Author: Fauxpress
 * Version: 0.0.1
 * Requires at least: 6.0
 * Requires PHP: 7.2
 * Author URI: https://example.com
 * License: GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: fauxpress-site-hygiene
 *
 * @package FauxpressSiteHygiene
 */Code language: PHP (php)

Next, let’s set up a namespace to use for the plugin.

<?php
/**
 * Plugin Name: Fauxpress Site Hygiene
 * Plugin URI: https://example.com
 * Description: A fake plugin demonstrating PSR-4 autoloading.
 * Author: Fauxpress
 * Version: 0.0.1
 * Requires at least: 6.0
 * Requires PHP: 7.2
 * Author URI: https://example.com
 * License: GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: fauxpress-site-hygiene
 *
 * @package FauxpressSiteHygiene
 */

namespace Fauxpress\SiteHygiene;
Code language: PHP (php)

The namespace used for the plugin is: Fauxpress\SiteHygiene

We now have a working plugin that can be activated. Let’s set up the autoloader next.

Setting up Composer

I have to admit that setting up Composer is not a straightforward process. But Composer is by far the easiest way to ensure your plugin has a PSR-4 structure.

Composer has an autoloader built in, which will save you from a ton of require_once calls.

To start, navigate to your plugin directory and type in: composer init.

Running composer init in the Plugin Directory
Running composer init in the Plugin Directory

After running composer init, you will receive several prompts to set up your project. Composer does its best to guess what namespace you’d like to use, but in this case I want to change it to fauxpress/sitehygiene. This is demonstrated in the video below:

For the most part, you can press enter through the entire wizard.

The resulting composer.json file will look similar to this:

{
    "name": "fauxpress/sitehygiene",
    "authors": [
        {
            "name": "Ronald Huereca",
            "email": "ronald@mediaron.com"
        }
    ],
    "require": {}
}Code language: JSON / JSON with Comments (json)

Adding Autoloading to Composer

With composer initiated, we can now open up the generated composer.json file in a text editor to set up autoloading.

Let’s modify our plugin’s composer.json to have its files moved to a lib folder instead of a vendor directory. Since most composer dependencies use the vendor directory, it’s hard to exclude composer dependencies from a build. Pushing what’s needed to a lib folder allows us to include the autoloader with the plugin’s release.

{
	"name": "fauxpress/sitehygiene",
	"authors": [
		{
			"name": "Ronald Huereca",
			"email": "ronald@mediaron.com"
		}
	],
	"require": {},
	"config": {
		"vendor-dir": "lib"
	}
}
Code language: JSON / JSON with Comments (json)

We’ll then tell composer that the structure is PSR-4 and the autoloader should look for files in an includes directory.

{
	"name": "fauxpress/sitehygiene",
	"authors": [
		{
			"name": "Ronald Huereca",
			"email": "ronald@mediaron.com"
		}
	],
	"require": {},
	"config": {
		"vendor-dir": "lib"
	},
	"autoload": {
		"psr-4": {
			"Fauxpress\\SiteHygiene\\": "includes/"
		}
	}
}
Code language: JSON / JSON with Comments (json)

You’ll notice that there’s a namespace of Fauxpress\SiteHygiene for the autoloader. This will be the PHP namespace used for the plugin.

Armed with the autoloader in composer.json, we can run composer install from the project’s directory.

Run composer install to Install the Autoloader
Run composer install to Install the Autoloader

The plugin should now have a directory structure as follows:

.
└── fauxpress-site-hygiene/
    ├── fauxpress-site-hygiene.php
    ├── composer.json
    ├── composer.lock
    └── lib/
        ├── composer/
        └── autoload.phpCode language: AsciiDoc (asciidoc)

Setting up the autoloader in the plugin

Armed with a composer autoloader, we can simply reference our autoloader in the plugin file.

<?php
/**
 * Plugin Name: Fauxpress Site Hygiene
 * Plugin URI: https://example.com
 * Description: A fake plugin demonstrating PSR-4 autoloading.
 * Author: Fauxpress
 * Version: 0.0.1
 * Requires at least: 6.0
 * Requires PHP: 7.2
 * Author URI: https://example.com
 * License: GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: fauxpress-site-hygiene
 *
 * @package FauxpressSiteHygiene
 */

namespace Fauxpress\SiteHygiene;

require __DIR__ . '/lib/autoload.php';

/**
 * Class Fauxpress
 */
class Fauxpress {

}Code language: PHP (php)

We’ll cover how to utilize the autoloader shortly, but first, let’s address an error you’re likely already encountering. WordPress coding standards do not approve of how we named and packaged our main plugin file.

Passing WordPress coding standards

Class file names warning in WordPress Coding Standards
Class file names warning in WordPress Coding Standards

As shown in the above screenshot, WordPress coding standards do not 100% agree with the PSR-4 structure we have created. It assumes a filename such as: class-fauxpress.php.

Let’s tell WordPress to ignore this filename requirement.

With PHPCS, you can enable custom rules per project. This involves creating a phpcs.xml.dist file and adding or subtracting any rules.

In our case, we want to hide the error that the filenames are wrong. We’ll add a simple rule to the file to tell it to ignore the filename standard.

<rule ref="WordPress-Core">
	<exclude name="WordPress.Files.FileName" />
</rule>Code language: HTML, XML (xml)

A full version of phpcs.xml.dist is shown below:

<?xml version="1.0"?>
<ruleset name="Fauxpress Site Hygiene Standards">
	<arg name="extensions" value="php" />
	<arg name="colors" />
	<arg value="s" /><!-- Show sniff codes in all reports -->

	<rule ref="WordPress-Core">
		<exclude name="WordPress.Files.FileName" />
		<exclude name="Generic.Arrays.DisallowShortArraySyntax" />
	</rule>

	<rule ref="WordPress-Docs"></rule>
	<rule ref="WordPress-Extra"></rule>
	<file>.</file>

	<exclude-pattern>/node_modules/</exclude-pattern>
	<exclude-pattern>/vendor/</exclude-pattern>
</ruleset>
Code language: HTML, XML (xml)

If you refresh your IDE (I use VS Code), the errors should be gone.

We now have a folder structure like this:

.
└── fauxpress-site-hygiene/
    ├── fauxpress-site-hygiene.php
    ├── composer.json
    ├── composer.lock
    ├── phpcs.xml.dist
    └── lib/
        ├── composer/
        └── autoload.phpCode language: AsciiDoc (asciidoc)

Creating the FauxPress Plugin

We’ll be filling out the plugin by introducing three admin options:

  1. Disable comments.
  2. Enable no-index of the site.
  3. Show a site-wide admin notice.

We won’t be implementing the logic of the features, but instead demonstrating the PSR-4 structure in action.

Initializing the plugin

Let’s begin by initializing the plugin and its textdomain.

<?php
/**
 * Plugin Name: Fauxpress Site Hygiene
 * Plugin URI: https://example.com
 * Description: A fake plugin demonstrating PSR-4 autoloading.
 * Author: Fauxpress
 * Version: 0.0.1
 * Requires at least: 6.0
 * Requires PHP: 7.2
 * Author URI: https://example.com
 * License: GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: fauxpress-site-hygiene
 *
 * @package FauxpressSiteHygiene
 */

namespace Fauxpress\SiteHygiene;

require __DIR__ . '/lib/autoload.php';

/**
 * Class Fauxpress
 */
class Fauxpress {
	/**
	 * Class runner.
	 */
	public function run() {
		add_action( 'init', array( $this, 'init' ) );
	}

	/**
	 * Init the class.
	 */
	public function init() {
		load_plugin_textdomain( 'fauxpress-site-hygiene' );
	}
}

add_action(
	'plugins_loaded',
	function () {
		$fauxpress = new Fauxpress();
		$fauxpress->run();
	}
);
Code language: PHP (php)

Using the plugins_loaded action, I can instantiate my class and begin plugin initialization.

Creating the Admin Panel options

First, I’m going to create my includes folder, add an Admin folder, and add a file within it named Init.php.

.
└── fauxpress-site-hygiene/
    ├── fauxpress-site-hygiene.php
    └── includes/
        └── Admin/
            └── Init.phpCode language: AsciiDoc (asciidoc)

Since the namespace of the plugin is Fauxpress\SiteHygiene, the expected namespace in Init.php would be: Fauxpress\SiteHygiene\Admin since it’s in an Admin folder. This is the magic of PSR-4, as the namespaces line up with the folder structure.

Here’s some placeholder logic inside Init.php that creates an admin options placeholder.

<?php
/**
 * Admin init class.
 *
 * @package FauxpressSiteHygiene
 */

namespace Fauxpress\SiteHygiene\Admin;

/**
 * Admin init class.
 */
class Init {
	/**
	 * Class runner.
	 */
	public function run() {
		add_action( 'admin_menu', array( $this, 'add_admin_menu' ) );
	}

	/**
	 * Initialize the admin menu.
	 */
	public function add_admin_menu() {
		add_options_page(
			'Fauxpress Site Hygiene',
			'Fauxpress Site Hygiene',
			'manage_options',
			'fauxpress-site-hygiene',
			array( $this, 'render_admin_page' ),
			10
		);
	}

	/**
	 * Render the admin page.
	 */
	public function render_admin_page() {
		?>
		<div class="wrap">
			<h1>Fauxpress Site Hygiene</h1>
		</div>
		<?php
	}
}Code language: PHP (php)

From our main plugin file, we can reference Init.php as follows:


namespace Fauxpress\SiteHygiene;

require __DIR__ . '/lib/autoload.php';

/**
 * Class Fauxpress
 */
class Fauxpress {
	/**
	 * Class runner.
	 */
	public function run() {
		add_action( 'init', array( $this, 'init' ) );

		$admin_init = new Admin\Init();
		$admin_init->run();
	}

	/**
	 * Init the class.
	 */
	public function init() {
		load_plugin_textdomain( 'fauxpress-site-hygiene' );
	}
}

add_action(
	'plugins_loaded',
	function () {
		$fauxpress = new Fauxpress();
		$fauxpress->run();
	}
);
Code language: PHP (php)

Since the namespace is already set, we don’t have to manually type out: Fauxpress\SiteHygiene\Admin\Init(). Instead, we can just use: new Admin\Init(). This is the autoloader at work.

Here’s our admin settings so far:

Fauxpress Admin Panel Placeholder
Fauxpress Admin Panel Placeholder

To house our options, I’ll create a new file called Options.php and place it in the root of the includes folder:

.
└── fauxpress-site-hygiene/
    ├── fauxpress-site-hygiene.php
    └── includes/
        ├── Options.php
        └── Admin/
            └── Init.php
Code language: AsciiDoc (asciidoc)

This file will hold our defaults and allow us to retrieve options.

<?php
/**
 * Helper functions for the plugin's options.
 *
 * @package FauxpressSiteHygiene
 */

namespace Fauxpress\SiteHygiene;

/**
 * Class Options
 */
class Options {

	/**
	 * A static cached version of the main options for the plugin.
	 *
	 * @var array $options
	 */
	private static $options = array();

	/**
	 * Options key.
	 *
	 * @var string $options_key The key when saving the options.
	 */
	private static $options_key = 'fauxpress';

	/**
	 * Retrieve default options for the plugin.
	 */
	public static function get_defaults() {
		$defaults = array(
			'disable_all_comments'  => false,
			'disable_site_indexing' => false,
			'admin_notice_text'     => '',
		);
		return $defaults;
	}

	/**
	 * Retrieve the plugin's options
	 *
	 * Retrieve the plugin's options based on key.
	 *
	 * @since 1.0.0
	 *
	 * @param bool $force_reload Whether to retrieve cached options or forcefully retrieve from the database.

	 * @return array|string All options for key, value for $option, empty array on failure.
	 */
	public static function get_options( bool $force_reload = false ) {

		// Try to get cached options value.
		$options = self::$options;

		// Retrieve fresh options if empty or force_reload is true.
		if ( empty( $options ) || true === $force_reload ) {
			$options = get_option( self::$options_key, array() );
		}

		// Parse options with defaults and update options static var.
		$options       = wp_parse_args( $options, self::get_defaults() );
		self::$options = $options;

		return $options;
	}

	/**
	 * Save options for the plugin.
	 *
	 * @param array|string $options Options to save.
	 * @param bool         $force True to forcefully retrieve fresh options.
	 *
	 * @return array|null  Options.
	 */
	public static function update_options( $options = array(), $force = false ) {

		$cached_options = self::get_options( $force );

		if ( is_array( $cached_options ) && is_array( $options ) ) {
			$cached_options = $options;
			$cached_options = wp_parse_args( $cached_options, self::get_defaults() );
			update_option( self::$options_key, $cached_options );
		} elseif ( is_array( $options ) ) {
			$cached_options = wp_parse_args( $options, $cached_options );
			update_option( self::$options_key, $cached_options );
		} else {
			return null;
		}
		self::$options = $cached_options;

		return self::$options;
	}

	/**
	 * Reset the options to defaults.
	 */
	public static function reset_options() {
		$new_options = self::get_defaults();
		self::update_options( $new_options, false );
		return $new_options;
	}
}Code language: PHP (php)

To interact with the options, I’ll create a new file called Settings.php and place it in the Admin folder:

fauxpress-site-hygiene
  fauxpress-site-hygiene.php
  includes/
    Options.php
    Admin/
      Init.php
      Settings.phpCode language: AsciiDoc (asciidoc)

At the top of Settings.php, I can use the Options class by referencing its namespace:

<?php
/**
 * Admin settings class.
 *
 * @package FauxpressSiteHygiene
 */

namespace Fauxpress\SiteHygiene\Admin;

use Fauxpress\SiteHygiene\Options;

/**
 * Admin settings class.
 */
class Settings {

}Code language: PHP (php)

From there, I can utilize the WordPress Settings API to present my options.

<?php
/**
 * Admin settings class.
 *
 * @package FauxpressSiteHygiene
 */

namespace Fauxpress\SiteHygiene\Admin;

use Fauxpress\SiteHygiene\Options;

/**
 * Admin settings class.
 */
class Settings {
	/**
	 * Class runner.
	 */
	public function run() {
		add_action( 'admin_init', array( $this, 'add_admin_init' ) );
	}

	/**
	 * Initialize the admin menu.
	 */
	public function add_admin_init() {
		// Register individual settings.
		register_setting(
			'fauxpress',
			'fauxpress',
			array(
				'sanitize_callback' => function ( $in ) {
					return array(
						'disable_all_comments'  => ! empty( $in['disable_all_comments'] ),
						'disable_site_indexing' => ! empty( $in['disable_site_indexing'] ),
						'admin_notice_text'     => sanitize_text_field( $in['admin_notice_text'] ?? '' ),
					);
				},
			)
		);

		// Register sections.
		add_settings_section( 'fauxpress_section', 'Fauxpress Site Hygiene', '__return_empty_string', 'fauxpress' );

		// Register fields.
		add_settings_field( 'disable_all_comments', 'Disable All Comments', array( $this, 'render_admin_field_disable_all_comments' ), 'fauxpress', 'fauxpress_section', array( 'label_for' => 'disable_all_comments' ) );
		add_settings_field( 'disable_site_indexing', 'Disable Site Indexing', array( $this, 'render_admin_field_disable_site_indexing' ), 'fauxpress', 'fauxpress_section', array( 'label_for' => 'disable_site_indexing' ) );
		add_settings_field( 'admin_notice_text', 'Admin Notice Text', array( $this, 'render_admin_field_admin_notice_text' ), 'fauxpress', 'fauxpress_section', array( 'label_for' => 'admin_notice_text' ) );
	}

	/**
	 * Render the admin field.
	 */
	public function render_admin_field_disable_all_comments() {
		$options = Options::get_options();
		?>
		<input type="hidden" name="fauxpress[disable_all_comments]" value="0" />
		<input type="checkbox" name="fauxpress[disable_all_comments]" value="1" <?php checked( (bool) $options['disable_all_comments'], true ); ?> id="disable_all_comments" />
		<?php
	}

	/**
	 * Render the admin field.
	 */
	public function render_admin_field_disable_site_indexing() {
		$options = Options::get_options();
		?>
		<input type="hidden" name="fauxpress[disable_site_indexing]" value="0" />
		<input type="checkbox" name="fauxpress[disable_site_indexing]" value="1" <?php checked( (bool) $options['disable_site_indexing'], true ); ?> id="disable_site_indexing" />
		<?php
	}

	/**
	 * Render the admin field.
	 */
	public function render_admin_field_admin_notice_text() {
		$options = Options::get_options();
		?>
		<input type="text" placeholder="Enter admin notice text" name="fauxpress[admin_notice_text]" value="<?php echo esc_attr( $options['admin_notice_text'] ); ?>" id="admin_notice_text" />
		<?php
	}
}Code language: PHP (php)

And finally, in the Init.php file, I can reference the settings API.

<?php
/**
 * Admin init class.
 *
 * @package FauxpressSiteHygiene
 */

namespace Fauxpress\SiteHygiene\Admin;

/**
 * Admin init class.
 */
class Init {
	/**
	 * Class runner.
	 */
	public function run() {
		add_action( 'admin_menu', array( $this, 'add_admin_menu' ) );
	}

	/**
	 * Initialize the admin menu.
	 */
	public function add_admin_menu() {
		add_options_page(
			'Fauxpress Site Hygiene',
			'Fauxpress Site Hygiene',
			'manage_options',
			'fauxpress-site-hygiene',
			array( $this, 'render_admin_page' ),
			10
		);
	}

	/**
	 * Render the admin page.
	 */
	public function render_admin_page() {
		?>
		<div class="wrap">
			<form method="post" action="options.php">
				<?php
				settings_fields( 'fauxpress' );
				do_settings_sections( 'fauxpress' );
				submit_button();
				?>
			</form>
		</div>
		<?php
	}
}
Code language: PHP (php)

I now have a functional admin panel.

Test Admin Panel Using Settings API
Test Admin Panel Using Settings API

Conclusion

In conclusion, in this tutorial, we:

  1. Set up a plugin with namespace: Fauxpress\SiteHygiene
  2. Set up a composer autoloader.
  3. Set up an options class and two admin classes to demonstrate autoloading.
  4. Used the Settings API to display several admin options.

Setting up PSR-4 in a WordPress plugin can be straightforward, but interacting with the files and structure through WordPress can be a bit of trial and error.

If you have any comments or questions, please leave a comment below. Thank you so much for reading.

Like the tutorial you just read? There's more like it.

There's more where that came from. Enter your email and I'll send you more like it.

This field is for validation purposes and should be left unchanged.

Ask a Question or Leave Feedback

Your email address will not be published. Required fields are marked *