Create a custom HivePress extension
In this tutorial, we'll create a custom HivePress extension that allows users to:
- Follow or unfollow any vendor
- View listings from the followed vendors on a single page
- Get emails about new listings from the followed vendors
- Unfollow all vendors with one click
While this extension is pretty simple, it covers the main aspects of the HivePress framework, so by the end of this tutorial, you should be able to create your own extension for HivePress.
Before we begin, please make sure that you have a working WordPress installation and at least basic WordPress development skills. The result of this tutorial is available on GitHub, so you can browse the complete source code or download the extension to test it locally.
First, create a directory in the
wp-content/plugins
WordPress subdirectory. The directory name will be used as the extension identifier, so make sure it's unique enough to avoid conflicts with other HivePress extensions (use lowercase letters, numbers, and hyphens only).For this tutorial, we'll name it "foo-followers", where "foo" is a unique prefix (e.g. your company name or abbreviation), and the "followers" part describes the extension purpose.
Next, create a PHP file with the same name inside the extension directory. This is the main file that is loaded by WordPress automatically when the extension is active.
<?php
/**
* Plugin Name: Followers for HivePress
* Description: Allow users to follow vendors.
* Version: 1.0.0
* Author: Foo
* Author URI: https://example.com/
* Text Domain: foo-followers
* Domain Path: /languages/
*/
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
// Register extension directory.
add_filter(
'hivepress/v1/extensions',
function( $extensions ) {
$extensions[] = __DIR__;
return $extensions;
}
);
As you can see, this file contains the extension details, such as:
- Plugin Name & Description - the extension name and short description of its purpose;
- Author & Author URI - your or your company name with an optional website URL;
- Text Domain - used for translating the extension, matches the main file name.
If you plan to distribute your extension, please name it "Something for HivePress" rather than "HivePress Something" to avoid trademark violation.
Also, there’s a simple code that prevents direct file access by URL so that it can be loaded by WordPress only. We’ll add the same code to every file created in this tutorial:
defined( 'ABSPATH' ) || exit;
The only thing the main file does is register the extension directory via the
hivepress/v1/extensions
hook to allow HivePress to detect and load all the other extension files automatically:add_filter(
'hivepress/v1/extensions',
function( $extensions ) {
$extensions[] = __DIR__;
return $extensions;
}
);
If you plan to submit your extension to the WordPress.org repository, make sure to follow its guidelines and add the
readme.txt
along with the license file.After you create the main file, go to WordPress > Plugins and activate the extension:

In HivePress, components are PHP classes used to group actions, filters, and helper functions. Let’s create an empty component and add the extension-specific functions to it later.
Create a
class-followers.php
file in the includes/components
extension subdirectory. Notice that it has the class-
prefix, and its name matches the extension name. It must be unique enough to avoid conflicts with other HivePress components (use lowercase letters, numbers, and hyphens only).The PHP class name must be based on the file name, but with underscores instead of hyphens and no lowercase restriction (e.g.
Foo_Bar
class for the class-foo-bar.php
file).<?php
namespace HivePress\Components;
use HivePress\Helpers as hp;
use HivePress\Models;
use HivePress\Emails;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* Component class.
*/
final class Followers extends Component {
/**
* Class constructor.
*
* @param array $args Component arguments.
*/
public function __construct( $args = [] ) {
// Attach functions to hooks here (e.g. add_action, add_filter).
parent::__construct( $args );
}
// Implement the attached functions here.
}
If your extension is simple enough, you can fully implement it within a component and skip other steps, but we recommend using the HivePress framework where possible.
In HivePress, models are PHP classes that represent the WordPress database entities such as posts, comments, or terms. Using models makes working with the database much easier.
Let’s create a
Follow
model for storing the follower ID along with the followed vendor ID. Create a class-follow.php
file in the includes/models
extension subdirectory. The file and PHP class naming conventions are the same as for components.<?php
namespace HivePress\Models;
use HivePress\Helpers as hp;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* Model class.
*/
class Follow extends Comment {
/**
* Class constructor.
*
* @param array $args Model arguments.
*/
public function __construct( $args = [] ) {
$args = hp\merge_arrays(
[
'fields' => [
'user' => [
'type' => 'id',
'required' => true,
'_alias' => 'user_id',
'_model' => 'user',
],
'vendor' => [
'type' => 'id',
'required' => true,
'_alias' => 'comment_post_ID',
'_model' => 'vendor',
],
],
],
$args
);
parent::__construct( $args );
}
}
Notice that the model class is based on the
Comment
class. This means that the model objects will be stored as WordPress comments of a custom type. We implement the model this way because comments are linked to both users and posts in the WordPress database schema. Since vendors are implemented as posts of a custom type, we can use this model to store both the follower (user) and the followed vendor IDs.The
Follow
model contains 2 fields:- user - mapped to the
user_id
database field, used for storing the follower ID; - vendor - mapped to the
comment_post_ID
database field, used for storing the followed vendor ID.
Also, if the model is based on the
Comment
class, WordPress will show the model objects in the comment feeds. To keep the model objects hidden, create a comment-types.php
file in the includes/configs
extension subdirectory:<?php
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
return [
'follow' => [
'public' => false,
],
];
The code above contains a configuration that makes the
follow
comment type registered by the model we created private.Let’s create a template for a page that displays all listings from the followed vendors. Create a
class-listings-feed-page.php
file in the includes/templates
extension subdirectory. The file and PHP class naming conventions are the same as for components.It’s good practice to follow the
{entity}-{context}-{layout}
pattern for naming templates (e.g. Listing_Edit_Page
, Vendor_View_Block
).<?php
namespace HivePress\Templates;
use HivePress\Helpers as hp;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* Template class.
*/
class Listings_Feed_Page extends User_Account_Page {
/**
* Class constructor.
*
* @param array $args Template arguments.
*/
public function __construct( $args = [] ) {
$args = hp\merge_trees(
[
'blocks' => [
'page_content' => [
'blocks' => [
'listings' => [
'type' => 'listings',
'columns' => 2,
'_order' => 10,
],
'listing_pagination' => [
'type' => 'part',
'path' => 'page/pagination',
'_order' => 20,
],
],
],
],
],
$args
);
parent::__construct( $args );
}
}
As you can see, the template class is based on the
User_Account_Page
class. This means that the template inherits the user account page layout and adds custom blocks to it.The template we created adds 2 blocks to the
page_content
area:- listings - displays listings for the current page;
- listing_pagination - displays the page links for navigation.
The block names must be unique within the template scope. Each block is defined as an array containing the block type and extra parameters used to render the block.
Now, we need to define custom URLs that will render the template we created and perform specific actions, such as following or unfollowing a vendor. In HivePress, this can be done using controllers – PHP classes that define URL routes and implement actions corresponding to them.
Create a
class-followers.php
file in the includes/controllers
extension subdirectory. The file and PHP class naming conventions are the same as for components.<?php
namespace HivePress\Controllers;
use HivePress\Helpers as hp;
use HivePress\Models;
use HivePress\Blocks;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* Controller class.
*/
final class Followers extends Controller {
/**
* Class constructor.
*
* @param array $args Controller arguments.
*/
public function __construct( $args = [] ) {
$args = hp\merge_arrays(
[
'routes' => [
// Define custom URL routes here.
],
],
$args
);
parent::__construct( $args );
}
// Implement the route actions here.
}
Let's define a new URL route in the controller constructor:
'listings_feed_page' => [
'title' => esc_html__( 'Feed', 'foo-followers' ),
'base' => 'user_account_page',
'path' => '/feed',
'redirect' => [ $this, 'redirect_feed_page' ],
'action' => [ $this, 'render_feed_page' ],
'paginated' => true,
],
If you add new or change any of the existing URL routes, don't forget to refresh permalinks in the Settings > Permalinks section.
The route we defined uses these parameters:
- title - used for the page title and menu label;
- base - used for inheriting another route
path
; - path - the relative route URL path;
- redirect - points to the URL redirect function;
- action - points to the route action function;
- paginated - flag required for paginated URLs.
Based on the parameter values, the
listings_feed_page
route has the /account/feed
URL, renders a page with the “Feed” title and supports pagination.Next, let's implement the redirect and action functions for this route below the constructor:
/**
* Redirects listing feed page.
*
* @return mixed
*/
public function redirect_feed_page() {
// Check authentication.
if ( ! is_user_logged_in() ) {
return hivepress()->router->get_return_url( 'user_login_page' );
}
// Check followed vendors.
if ( ! hivepress()->request->get_context( 'vendor_follow_ids' ) ) {
return hivepress()->router->get_url( 'user_account_page' );
}
return false;
}
/**
* Renders listing feed page.
*
* @return string
*/
public function render_feed_page() {
// Create listing query.
$query = Models\Listing::query()->filter(
[
'status' => 'publish',
'vendor__in' => hivepress()->request->get_context( 'vendor_follow_ids' ),
]
)->order( [ 'created_date' => 'desc' ] )
->limit( get_option( 'hp_listings_per_page' ) )
->paginate( hivepress()->request->get_page_number() );
// Set request context.
hivepress()->request->set_context(
'post_query',
$query->get_args()
);
// Render page template.
return ( new Blocks\Template(
[
'template' => 'listings_feed_page',
'context' => [
'listings' => [],
],
]
) )->render();
}
When the route URL is visited, the redirect function is called first. In our case, it checks if the current user is logged in and has any followed vendor IDs. It returns the corresponding redirect URL or
false
if all checks are passed.If there was no redirect, the action function is called next. As you can see, it creates a query for listings published by the followed vendors, sets it as the main page query, and finally renders the template we created earlier. Notice that this function returns the rendered HTML instead of outputting it with
echo
.Now, if you refresh permalinks in Settings > Permalinks and try to visit the
/account/feed
URL, you will be redirected because you haven’t followed any vendors yet.We already used a code that checks if the current user follows any vendors by checking the
vendor_follow_ids
value in the request context, but there’s no function that sets this value in context yet. Add this code to the component constructor:add_filter( 'hivepress/v1/components/request/context', [ $this, 'set_request_context' ] );
The code above hooks a custom filtering function to the request context values. Let’s implement this function in the component below the constructor:
/**
* Sets request context for pages.
*
* @param array $context Context values.
* @return array
*/
public function set_request_context( $context ) {
// Get user ID.
$user_id = get_current_user_id();
// Get cached vendor IDs.
$vendor_ids = hivepress()->cache->get_user_cache( $user_id, 'vendor_follow_ids', 'models/follow' );
if ( is_null( $vendor_ids ) ) {
// Get follows.
$follows = Models\Follow::query()->filter(
[
'user' => $user_id,
]
)->get();
// Get vendor IDs.
$vendor_ids = [];
foreach ( $follows as $follow ) {
$vendor_ids[] = $follow->get_vendor__id();
}
// Cache vendor IDs.
hivepress()->cache->set_user_cache( $user_id, 'vendor_follow_ids', 'models/follow', $vendor_ids );
}
// Set request context.
$context['vendor_follow_ids'] = $vendor_ids;
return $context;
}
This function checks if the followed vendor IDs are cached for the current user, and if not, it queries the
Follow
model objects by user ID and fills an array of vendor IDs, then caches this array. Finally, it sets an array of vendor IDs in the vendor_follow_ids
context, this allows us to get it anywhere in the code this way:$vendor_ids = hivepress()->request->get_context( 'vendor_follow_ids' );
Also, the listing feed page we created doesn’t have any links on the front-end yet, so let’s add it to the user account menu. Add this code to the component constructor:
add_filter( 'hivepress/v1/menus/user_account', [ $this, 'add_menu_item' ] );
The code above hooks a custom filtering function to the user account menu parameters. Next, implement this function in the component below the constructor:
/**
* Adds menu item to user account.
*
* @param array $menu Menu arguments.
* @return array
*/
public function add_menu_item( $menu ) {
if ( hivepress()->request->get_context( 'vendor_follow_ids' ) ) {
$menu['items']['listings_feed'] = [
'route' => 'listings_feed_page',
'_order' => 20,
];
}
return $menu;
}
As you can see, it adds a custom menu item linked to the
listings_feed_page
route we created previously. You can adjust the _order
parameter value to change the menu item position. The menu item will appear only if the current user follows any vendors.Let’s also create URL routes that allow users to follow or unfollow vendors. Since these routes don’t render anything and are used for performing actions only, we will define them as REST API routes. These routes don’t require the
title
, redirect
, and paginated
parameters, but other parameters are needed instead:- method - restricts the accepted HTTP method (e.g.
GET
,POST
); - rest - flag required for REST API routes.
It’s good practice to follow the
{entity}-{context}-{type}
pattern for naming routes (e.g. listing_view_page
, vendor_update_action
).'vendor_follow_action' => [
'base' => 'vendor_resource',
'path' => '/follow',
'method' => 'POST',
'action' => [ $this, 'follow_vendor' ],
'rest' => true,
],
'vendors_unfollow_action' => [
'base' => 'vendors_resource',
'path' => '/unfollow',
'method' => 'POST',
'action' => [ $this, 'unfollow_vendors' ],
'rest' => true,
],
The code above defines 2 REST API routes, both accept requests via the
POST
method. The first route will follow or unfollow a vendor on every subsequent request, while the second one will unfollow all vendors at once. Next, implement the action functions for these routes:/**
* Follows or unfollows vendor.
*
* @param WP_REST_Request $request API request.
* @return WP_Rest_Response
*/
public function follow_vendor( $request ) {
// Check authentication.
if ( ! is_user_logged_in() ) {
return hp\rest_error( 401 );
}
// Get vendor.
$vendor = Models\Vendor::query()->get_by_id( $request->get_param( 'vendor_id' ) );
if ( ! $vendor || $vendor->get_status() !== 'publish' ) {
return hp\rest_error( 404 );
}
// Get follows.
$follows = Models\Follow::query()->filter(
[
'user' => get_current_user_id(),
'vendor' => $vendor->get_id(),
]
)->get();
if ( $follows->count() ) {
// Delete follows.
$follows->delete();
} else {
// Add new follow.
$follow = ( new Models\Follow() )->fill(
[
'user' => get_current_user_id(),
'vendor' => $vendor->get_id(),
]
);
if ( ! $follow->save() ) {
return hp\rest_error( 400, $follow->_get_errors() );
}
}
return hp\rest_response(
200,
[
'data' => [],
]
);
}
/**
* Unfollows all vendors.
*
* @param WP_REST_Request $request API request.
* @return WP_Rest_Response
*/
public function unfollow_vendors( $request ) {
// Check authentication.
if ( ! is_user_logged_in() ) {
return hp\rest_error( 401 );
}
// Delete follows.
$follows = Models\Follow::query()->filter(
[
'user' => get_current_user_id(),
]
)->delete();
return hp\rest_response(
200,
[
'data' => [],
]
);
}
The
follow_vendor
function checks if the current user is logged in, gets a Vendor
object by ID, and queries the Follow
objects by the user and vendor IDs.Then, if any
Follow
objects are found, they are deleted. If not, a new Follow
object is created and saved in the database. This way, every subsequent call of this function will follow or unfollow a vendor, creating or deleting a Follow
object.The
unfollow_vendors
function checks if the current user is logged in, then queries the Follow
objects by the user ID and deletes them, thus unfollowing all vendors.Now we have all the URL routes and actions according to the extension requirements. You can view the complete controller source code on GitHub for reference.
We created REST API routes, but there are no links or forms on the front-end that send requests to these routes yet. Let’s add a toggle link that sends a request to the
vendor_follow_action
on click and add it somewhere on the vendor page. We need to create a new block type for this.In HivePress, block types are defined as PHP classes with properties and methods that determine the behavior and rendering of the block.
Create a
class-follow-toggle.php
file in the includes/blocks
extension subdirectory. The file and PHP class naming conventions are the same as for components.<?php
namespace HivePress\Blocks;
use HivePress\Helpers as hp;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* Block class.
*/
class Follow_Toggle extends Toggle {
/**
* Class constructor.
*
* @param array $args Block arguments.
*/
public function __construct( $args = [] ) {
$args = hp\merge_arrays(
[
'states' => [
[
'icon' => 'user-plus',
'caption' => esc_html__( 'Follow', 'foo-followers' ),
],
[
'icon' => 'user-minus',
'caption' => esc_html__( 'Unfollow', 'foo-followers' ),
],
],
],
$args
);
parent::__construct( $args );
}
/**
* Bootstraps block properties.
*/
protected function boot() {
// Get vendor from the block context.
$vendor = $this->get_context( 'vendor' );
if ( $vendor ) {
// Set URL for sending requests on click.
$this->url = hivepress()->router->get_url(
'vendor_follow_action',
[
'vendor_id' => $vendor->get_id(),
]
);
// Set active state if vendor is followed.
if ( in_array(
$vendor->get_id(),
hivepress()->request->get_context( 'vendor_follow_ids', [] )
) ) {
$this->active = true;
}
}
parent::boot();
}
}
Notice that the block class is based on the
Toggle
class – this is an existing block type available in HivePress, so all the properties and methods are inherited from it.The
Follow_Toggle
block type we created defines 2 states for the toggle, setting the Font Awesome icon name and a label for each state.Before the block is rendered, it fetches the
Vendor
object from the current template context and sets the toggle url
to the vendor_follow_action
route we created previously. It also enables the active
flag if the vendor ID is among the followed vendor IDs.This way, the toggle will show the “Follow” or “Unfollow” label on every subsequent click and send an AJAX request to the
vendor_follow_action
route URL. Also, it will show the “Unfollow” label by default if the vendor is already followed.It’s good practice to re-use the existing HivePress block types and avoid creating new ones if possible. In this case, we had to create a new block type because it implements a custom logic (the toggle state depends on the followed vendor IDs).
Next, let’s add this block to the vendor templates. Add this code to the component constructor:
add_filter( 'hivepress/v1/templates/vendor_view_block', [ $this, 'add_toggle_block' ] );
add_filter( 'hivepress/v1/templates/vendor_view_page', [ $this, 'add_toggle_block' ] );
The code above hooks a custom filtering function to the vendor template parameters. Now, implement this function in the component below the constructor:
/**
* Adds toggle block to vendor templates.
*
* @param array $template Template arguments.
* @return array
*/
public function add_toggle_block( $template ) {
return hp\merge_trees(
$template,
[
'blocks' => [
'vendor_actions_primary' => [
'blocks' => [
'vendor_follow_toggle' => [
'type' => 'follow_toggle',
'_order' => 50,
'attributes' => [
'class' => [ 'hp-vendor__action', 'hp-vendor__action--follow' ],
],
],
],
],
],
]
);
}
The function above filters the template parameters and adds a new block using the block type we created earlier. Let’s check if the toggle link is added on the front-end:

Now you can try clicking on the Follow toggle and check if the vendor listings appear on the listing feed page, and unfollow a vendor to check if listings disappear.
We still have one action left that is not used anywhere, the one that unfollows all vendors at once. So let’s create a form for it and add a button to show the form on click.
Create a
class-vendors-unfollow.php
file in the includes/forms
extension subdirectory. The file and PHP class naming conventions are the same as for components.<?php
namespace HivePress\Forms;
use HivePress\Helpers as hp;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* Form class.
*/
class Vendors_Unfollow extends Form {
/**
* Class constructor.
*
* @param array $args Form arguments.
*/
public function __construct( $args = [] ) {
$args = hp\merge_arrays(
[
'description' => esc_html__( 'Are you sure you want to unfollow all vendors?', 'foo-followers' ),
'action' => hivepress()->router->get_url( 'vendors_unfollow_action' ),
'method' => 'POST',
'redirect' => true,
'button' => [
'label' => esc_html__( 'Unfollow', 'foo-followers' ),
],
],
$args
);
parent::__construct( $args );
}
}
The form we’ve created defines these parameters:
- description - text displayed before the form;
- action - URL for sending requests on submission;
- method - HTTP method for sending requests (e.g.
POST
,GET
); - redirect - flag to refresh or redirect the page;
- button - the submit button parameters.
This form doesn’t contain fields, but you can define an array of fields in the
fields
parameter and the form will render them, sending the entered values with the request.Next, let’s add new blocks to the
Listings_Feed_Page
template we created earlier:'vendors_unfollow_link' => [
'type' => 'part',
'path' => 'vendor/follow/vendors-unfollow-link',
'_order' => 30,
],
'vendors_unfollow_modal' => [
'title' => esc_html__( 'Unfollow Vendors', 'foo-followers' ),
'type' => 'modal',
'blocks' => [
'vendors_unfollow_form' => [
'type' => 'form',
'form' => 'vendors_unfollow',
],
],
],
As you can see, there’s a
part
block that loads a specific HTML file and a modal
block that contains the form we’ve just created. The part block points to a non-existing file, so we need to create a vendors-unfollow-link.php
file in the templates/vendor/follow
extension subdirectory:<?php
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
?>
<a href="#vendors_unfollow_modal" class="button"><?php esc_html_e( 'Unfollow Vendors', 'foo-followers' ); ?></a>
It’s good practice to follow the
{entity}/{context}/{layout}
directory structure for template parts, this way you can easily find the template where the part is used.Now, let’s check the listing feed page. It should have the “Unfollow” button that opens a modal window on click. The modal window contains a form that sends a request to the
vendors_unfollow_action
route URL, thus unfollowing all vendors at once.
Finally, let’s add an email notification sent to users about new listings from the followed vendors.
Create a
class-listing-feed.php
file in the includes/emails
extension subdirectory. The file and PHP class naming conventions are the same as for components.<?php
namespace HivePress\Emails;
use HivePress\Helpers as hp;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* Email class.
*/
class Listing_Feed extends Email {
/**
* Class constructor.
*
* @param array $args Email arguments.
*/
public function __construct( $args = [] ) {
$args = hp\merge_arrays(
[
'subject' => esc_html__( 'New Listing', 'foo-followers' ),
'body' => esc_html__( 'Hi, %user_name%! There is a new listing "%listing_title%" in your feed, click on the following link to view it: %listing_url%', 'foo-followers' ),
],
$args
);
parent::__construct( $args );
}
}
The email we’ve just created defines these parameters:
- subject - the email subject;
- body - the email message with placeholders.
Next, let’s add a function that sends an email to the vendor followers if there’s a newly published listing. Add this code to the component constructor:
add_action( 'hivepress/v1/models/listing/update_status', [ $this, 'send_feed_emails' ], 10, 4 );
The code above hooks a custom function to the listing status change action. Now, implement this function in the component below the constructor:
/**
* Sends emails about a new listing.
*
* @param int $listing_id Listing ID.
* @param string $new_status New status.
* @param string $old_status Old status.
* @param object $listing Listing object.
*/
public function send_feed_emails( $listing_id, $new_status, $old_status, $listing ) {
// Check listing status.
if ( 'publish' !== $new_status || ! in_array( $old_status, [ 'auto-draft', 'pending' ] ) ) {
return;
}
// Get follows.
$follows = Models\Follow::query()->filter(
[
'vendor' => $listing->get_vendor__id(),
]
)->get();
foreach ( $follows as $follow ) {
// Get user.
$user = $follow->get_user();
// Send email.
( new Emails\Listing_Feed(
[
'recipient' => $user->get_email(),
'tokens' => [
'user_name' => $user->get_display_name(),
'listing_title' => $listing->get_title(),
'listing_url' => hivepress()->router->get_url( 'listing_view_page', [ 'listing_id' => $listing->get_id() ] ),
],
]
) )->send();