Jump to content

Sharing some helpful hooks and a couple of extras


FireWire
 Share

Recommended Posts

I didn't know where the right place to share this was on the forums, so I'll post it here since it may be helpful to those getting started with ProcessWire hooks, or some experienced ProcessWire developers who might find them useful. Either way, dear reader, If someone already wrote it, why write it again?

If you're someone with experience, feedback is welcome! If there are better or more efficient ways to do something, I would love being a student. Some of these may either address challenges that others have experienced as well or were inspired by the awesome community sharing their solutions. Kudos to all of the people out there helping all of us. If someone sees something that was solved elsewhere please share it in the comments to give credit.

I have to make a disclaimer- these have worked for me and while most of them are ready to copy/paste, a few of them are going to need customization or tweaking to make them work for your use case. I don't currently have the resources (time) to provide a lot of support. Some of these were slightly rewritten or adapted for the examples. If you run into issues, the best thing to do is research the solution so that you know exactly what is happening in your application. If you adapt something, fix a bug, or address an edge case, it would be great if you can come back and share that.

Be smart, if you're going to run hooks that modify or create data, run a DB backup first. This is the part where I say "I'm not responsible if your site blows up". I don't think that's possible, but do the right thing.

There are dozens of hooks in the project I am sharing these from, and to manage that I created a file structure to handle this because there were far too many to put in one file and keeping the init.php and ready.php files clean really makes a huge difference in maintainability. Being able to jump between files by filename is a supremely efficient way to work as well. The filenames don't matter, they're there to identify the files and make it easy to locate/switch between them.

Here's my approach to directory organization:

/site
- hooks
-- HookUtils
-- init
-- lazy_cron
-- ready
init.php
ready.php

The ready.php file contents:

<?php namespace ProcessWire;

if(!defined("PROCESSWIRE")) die();

/** @var ProcessWire $wire */

// Import all ready hooks
foreach (glob(__DIR__ . '/hooks/ready/*.php') as $hook) {
  require_once $hook;
}

The init.php file contents:

<?php namespace ProcessWire;

if(!defined("PROCESSWIRE")) die();

/** @var ProcessWire $wire */

// Import all init hooks
foreach (glob(__DIR__ . '/hooks/init/*.php') as $hook) {
  require_once $hook;
}

// Import all LazyCron hooks, import after init hooks as there may be dependencies
foreach (glob(__DIR__ . '/hooks/lazy_cron/*.php') as $hook) {
  require_once $hook;
}

 

Operational Hooks

Here are some favorites.

 

Sort items in a Repeater matrix when a page is saved

This one helped sort RM items by a date subfield to help the user experience when editing pages. This implementation is configured to only fire on a specific template but can be modified to fire everywhere if modified to check that a field exists on the page being saved first. This was adapted from an answer here in the PW forum but can't find the original post, so I'm going to include it. If you're having issues getting items to sort the way you want, check out this post about natural sorting, which also works elsewhere in ProcessWire.

Github Gist

 

Automatically add a new child page under a page having a specific template when created

This automatically creates a new child page and saves it when a page having a specific template is created. This also has the ability to show a message to the user in the admin when new page(s) have been created by this hook. It is also error safe by catching any potential exceptions which will show an informative error to the admin user and log the exception message. The messaging/logging operation is abstracted to a separate object to allow reuse if creating multiple pages.

Github Gist

 

Conditionally show the user a message while editing a page

This one shows a message on a page with a specific template under specific conditions. May be page status, field value, type of user, etc. Visual feedback when editing complex pages can be very helpful, especially when an operation may or may not take place depending on factors like the values of multiple fields. This can reduce the amount of explanations needed on each field or training required for users to use a ProcessWire application. In my case, a message is shown if the state of a page indicates that another operation that is triggered by other hooks will or will not run, which is something that the user doesn't directly trigger or may not be aware of.

Github Gist

 

Show the user a message when viewing the page tree

This is intended to display a message, warning, or error when the page tree is viewed, such as on login, but in this case executes any time the main page tree is viewed to provide consistent communication and awareness. In my case it displays if there is an activity page located under an "Uncategorized" page for an event. This is something that may be buried in the page hierarchy and not noticeable, but if an activity isn't categorized, then is isn't visible on the website, and if it's not visible on the website, people aren't seeing it or buying tickets. So having a persistent message can bring visibility to important but otherwise potentially unnoticed issues. Or you can just say hi and something nice.

Github Gist

 

Hook Enhancement - Fast user switching

Hooks can run on triggers that vary widely. Some can and should be identified as those that are triggered by the current user, others may be more autonomous like executing via cron. There may be other hooks that are executed by a user that isn't logged in. Depending on the type of action and your need to identify or track it, switching from the current user to another user created specifically to handle certain tasks can be very helpful. ProcessWire tracks a number of things that are attributed to users- log entries note the user, the user that creates pages is stored, the user that last updated the page is stored, etc. You may want to know who did what when, or only take action if the last user that touched something was X and not Y.

I created a separate user that has been provided only the specific permissions it needs to complete jobs that are triggered by hooks or crons. Creating a user with less permissions may also help prevent accidental behaviors, or at least help you be very intentional in determining what actions are delegated. Creating custom permissions is also useful. With a dedicated user I can see explicitly that the last update on some pages were made by an autonomous script that syncs information between the ProcessWire application and a third party platform.

Github Gist - Fast user switcher

Github Gist - Example of switching users in a hook

 

Fast, powerful, and very (very) easy custom admin buttons

I needed a way to add custom interactive buttons that had some specific requirements.

  • Needs to be a button that can be clicked by the user and does something
  • Can be conditionally shown to the user with an alternate message if that action is not available
  • Needs to do something on the server and interact with ProcessWire

Here's what that looked like for my application. The green "Refresh Activity" button in the top right. That's a custom button and you don't have to author an Inputfield module to get it. When a user clicks that button, it sends a request to the server with GET variables that are recognized in a hook, actions are taken, then a nice message indicating success or failure is shown to the user.

image.thumb.png.6f207df1ee9e73a60d8a30bea141a37a.png

To do this you'll need to install FieldtypeRuntimeOnly and create a new field. Following the documentation for that field, create a button with a URL to the current page with GET variables appended. Then create a hook that watches for the specific GET variable that executes if it's present. Shoutout to @Robin S for helping make short work of a potentially complex task.

Note that the field code contains JS that handles the URL on page load. Since the hook is looking for a GET variable in the URL, using the back button or refreshing the page will cause the action to run twice. The JS in that example removes the entry from the browser history and also removes the GET parameter after the page loads if it's present.

Github Gist - An example gist for the hook that handles the action

Github Gist - An example of the FieldtypeRuntimeOnly code that is displayed and interacted with by the user.

 

Automatically convert logged object or array data to JSON

If you're using the outstanding Logs JSON Viewer (yet another great one by @Robin S module, then this hook makes for a thoroughly enjoyable logging experience. Using array or stdClass data when logging your values helps store additional information in an organized way

Github Gist

<?php

$log->save('log_name_here', 'Regular string message'); // Remains a string

$log->save('log_name_here', ['gets' => 'converted', 'to' => 'json']);

$log->save('log_name_here', (object) ['is' => 'stdClass', 'object' => 'friendly']);

 

Use a separate field to store address data for a FieldtypeMapMarker field

This one is really simple, more just sharing an implementation and idea, but proved valuable for reducing data redundancy. I have a FieldtypeMapMarker field but the way that I needed to store address data was much better suited to using multiple fields for things like street, city, state, and zip code. I wanted those fields to be the "controlling" fields for the map marker field to prevent needing to edit 2 fields to keep updated, or accidental content diversion between them. On page save the value from the address fields are pulled and converted into a single string that is added to the FieldtypeMapMarker field's "address" property. I used a Custom Field (ProFields) for my address fields but this can be modified to suit your use case very easily.

Github Gist

You might also consider hiding the address input on the FieldtypeMapMarker field itself to reduce confusion since the values will be updated automatically anyway. You'll need to have this in a file that is appended to the Admin styles

/*
  You can find the appropriate class for the template you are applying this to in the <body> element when editing a page
  You can omit that if you want to apply this everywhere
*/

.ProcessPageEdit-template-your_template_name .InputfieldMapMarker.Inputfield_activity_location .InputfieldMapMarkerAddress,
.ProcessPageEdit-template-your_template_name .InputfieldMapMarker.Inputfield_activity_location .InputfieldMapMarkerToggle,
.ProcessPageEdit-template-your_template_name .InputfieldMapMarker.Inputfield_activity_location .InputfieldMapMarkerLat,
.ProcessPageEdit-template-your_template_name .InputfieldMapMarker.Inputfield_activity_location .InputfieldMapMarkerLng {
   display: none !important;
}
<?php
// Add this to your ready.php file or ready-firing hook to insert the file containing that CSS to your admin.

$config->styles->add("/path/to/your/custom/admin/css/file.css");

 

Not-A-Hook Bonus - Here's code for an interactive Google Map

Renders a Google Map using a FieldtypeMapMarker field, a separate address field, Alpine.js, and Tailwind. You'll need a Google Maps API key, a styled map ID from your Google Developer account, and the aforementioned fields. I wrote it using the latest Google Maps API. Saved you some time. You'll probably need to tweak it. I adapted this so if you find a bug please let me know and I'll update the gist.

Note- this makes use of the AlpineJS Intersect plugin to improve performance by only loading/initializing the map when a user scrolls close enough to it. If you don't want that, remove the x-intersect directive. If you want to see it in action, you can check it out here.

Github Gist

 

Hook Support Class - A static method class to translate a field into all languages automatically

If you use the Fluency translation module, this is a class that will help out with translating a field into all languages programmatically. Sharing this here because the next hook uses this as a dependency. I keep this in the HookUtils directory noted in the file structure above. Usage is demonstrated in the next hook.

Github Gist

 

Translate all translatable fields using Fluency on page save whether from UI or API.

This is useful for instances where you want a page translated automatically and especially helpful when you are creating pages programmatically. This requires the above hook support class, as well as Fluency connected to an API account. Here are things that must be kept in mind. Please read them, the code for the hook, and the code for the support class to ensure that it works to your needs.

  • You should modify Fluency before using this, really. Change the value of CACHE_EXPIRY on line 19 in the TranslationCache file to WireCache::expireNever. Do this to prevent chewing through your API usage from month to month on repeat translations. This will become standard in the next release of Fluency.
  • This is an expensive operation in terms of API usage, which is why you very much should modify the caching behavior. This hook does not make an effort to determine which fields have changed before translating because it doesn't really matter if the translation is already cached.
  • First time translations of pages with a significant amount of fields/content may be slow, like noticeably slower first time page save because this operation is only as fast as the speed of the request/response loop between ProcessWire and the translation API. Later page saves will be much faster thanks to cached translations. This will not attempt to translate empty fields, so those won't cause any delays.
  • This works with multi-language text/textarea/TinyMCE/CKEditor fields, RepeaterMatrix fields, and the newer Custom Fields (ProFields). Other fields haven't been tested, but it's definitely possible to adapt this to those needs.
  • I prefer to target specific templates with hooks, you can add multiple but be mindful of your use case.
  • Consider adding excluded fields to the array in the hook if it makes sense
  • Consider adding a field to enable/disable translations from the UI, a checkbox field or something
  • This hook is probably one of the uglier ones, sorry.
  • If you run out of API usage on your account, you're going to see a big ugly exception error on screen. This is due to Fluency not handling an account overage error properly because the return type was not as expected. Will be fixed in the next version of the module
  • This is one that may be tailored to my PW application, I think it's general enough to use as-is for your project, but testing is definitely required.
  • Read all the code please.

Github Gist

 

ProcessWire Object Method & Property Hooks

The following are custom methods that add functionality to native ProcessWire objects.

 

Add a getMatrixChildren() method to RepeaterMatrixPage objects

RepeaterMatrix fields represent nesting depth as an integer on each RepeaterMatrixPage item. So top level is 0, first nested level is 1, second 2, etc. When looping through RM items, determining nesting requires working with that integer. It works, but adding adding some functionality helps out. This is infinitely nestable, so accessing children, children of children, children of children of children, and so on works. Fun for the whole family. This was inspired by a forum post, another one I can't find...

Github Gist

<?php

// Access nested RepeaterMatrix items as child PageArray objects
$page->repeater_matrix_field->first()->getMatrixChildren(); // => PageArray

?>
<!-- Assists with rendering nested RM items in templates Sponsors are nested under sponsorship levels in the RM field -->
<div>
  <?php foreach ($page->sponsors as $sponsorshipLevel): ?>
    <h2><?=$sponsorshipLevel->title?></h2>

    <?php if ($sponsorshipLevel->getMatrixChildren()->count()): ?>
      <ul>
        
        <?php foreach ($sponsorshipLevel->getMatrixChildren() as $sponsor): ?>
          <li>
            <img src="<?=$sponsor->image->url?>" alt="<?=$sponsor->image->description?>">
            <?=$sponsor->title?>
          </li>
        <?php endforeach ?>
        
      </ul>
    <?php endif ?>

  <?php endforeach ?>
</div>

 

Add a resizeAspectRatio() method to PageImage objects

Adds a simple way to quickly resize an image to a specific aspect ratio. Use cases include sizing images for Google Structured Data and formatting images for consistency in image carousels. Could be improved by accepting second argument to specify an image width, but didn't fit my use case.

Github Gist

<?php 

$page->image_field->resizeAspectRatio('square')->url; // Alias for 1:1
$page->image_field->resizeAspectRatio('video')->url; // Alias for 16:9
$page->image_field->resizeAspectRatio('17:10')->url; // Arbitrary values accepted

 

Add a responsiveAttributes() method to PageImage objects

Adds a very helpful method to generate image variations and accompanying 'srcset' and 'sizes' attributes for any image. Designed to be very flexible and is Tailwind ready. Responsive sizing can be as simple or complex as your needs require. Includes an optional 'mobile' Tailwind breakpoint that matches a custom tailwind.config.js value:  screens: { 'mobile': '320px'}. I added this breakpoint largely to further optimize images for small screens. The array of Tailwind breakpoints and size definitions can be edited to suit your specific setup if there are customizations

When sizing for Tailwind, the last media query generated will automatically be switched to "min-width" rather than "max-width" to prevent problems arising from restricting widths. Example, you can specify values only for 'sm' and 'md' and the 'md' size will have the media query correctly adjusted so that it applies to all breakpoints above it.

Github Gist

<--
    The responsiveAttributes() returns a renderable attribute string: srcset="{generated values}" sizes="{generated values}"
-->
<-- Create responsive images with arbitrary width and height at breakpoints -->
<img src="<?=$page->image->url?>"
     <?=$page->image->responsiveAttributes([
       [240, 125, '(max-width: 300px)'],
       [225, 125, '(max-width: 600px)'],
       [280, 165, '(max-width: 900px)'],
       [210, 125, '(max-width: 1200px)'],
       [260, 155, '(min-width: 1500px)'],
     ])?>
     width="240" height="125"
     alt="<?=$page->image->description?>"
>

<-- Heights can be selectively ommitted by setting the height value to null -->
<img src="<?=$page->image->url?>"
     <?=$page->image->responsiveAttributes([
       [240, 125, '(max-width: 300px)'],
       [225, null, '(max-width: 600px)'],
       [280, 165, '(max-width: 900px)'],
       [210, null, '(max-width: 1200px)'],
       [260, null, '(min-width: 1500px)'],
     ])?>
     width="240" height="125"
     alt="<?=$page->image->description?>"
>
  
<-- Create responsive images with only widths at breakpoints -->
<img src="<?=$page->image->url?>"
     <?=page->image->responsiveAttributes([
       [240, '(max-width: 300px)'],
       [225, '(max-width: 600px)'],
       [280, '(max-width: 900px)'],
       [210, '(max-width: 1200px)'],
       [260, '(min-width: 1500px)'],
     ])?>
     width="240"
     height="125"
     alt="<?=$page->image->description?>"
>
  
<-- Create custom sizes matched to Tailwind breakpoints -->
<img src="<?=$page->image->url?>"
     <?=$page->image->responsiveAttributes([
       'mobile' => [240, 125], <!-- Custom tailwind directive -->
       'sm' => [225, 125],
       'md' => [280, 165],
       'lg' => [210, 125],
       'xl' => [260, 155],
     ])?>
     width="240" height="125"
     alt="<?=$page->image->description?>"
>
 
<!--
     Resizes width of image to fit Tailwind breakpoints, useful for full width images such as hero
     images, doesn't change height. Also accepts 'tw' as an alias for 'tailwind'
-->
<img src="<?=$page->image->url?>"
     <?=$heroImage->responsiveAttributes('tailwind')?>
     width="240" height="125"
     alt="<?=$page->image->description?>"
>

 

Add PHP higher-order function methods to WireArray and WireArray derived objects

WireArray objects are incredibly powerful and have tons of utility, but there are situations where I find myself needing to work with plain PHP arrays. I'm a very big fan of PHP's array functions that are efficient and make for clean readable code. I found myself often reaching for $wireArrayThing->getArray() to work with data then using functions like array_map, array_filter, and array_reduce. These return arrays, but could easily be modified to return WireArray objects if that is more helpful.

Github Gist

<?php 

// The EventPage page class has a method that determines sold out status from more than one source of data/page fields
// which means that it isn't queryable using a ProcessWire selector. This returns a single integer calculated from ticket availability
// of all events from non-queryable data.
$totalEventsAvailable = $eventPages->reduce(
  fn ($total, $eventPage) => $count = $eventPage->isActive() ? $total++ : $total, 0
);

// Requires using a page class to determine status reliant on multiple data points not queryable via a selector. Knowing what the event
// page is for an activity can't be determined using a selector for activity pages.
$displayableActivities = $matches->filterToArray(
  fn ($activityPage) => $activityPage->eventPage()->isPublic() && $activityPage->isActive()
);

// Iterating over each Page in a PageArray and processing data for sorting/ordering before rendering on a search results page
// Executed within a page class
$results = $pages->mapToArray(function($page) {
  return (object) [
    'page' => $page,
    'summary' => $this->createResultSummary(page: $page, maxLength: 750),
    'keywordMatchCount' => $this->getQueryMatchCount(page: $page),
  ];
});

 

Add an image orientation method/property to PageImage objects

Get the portrait or landscape orientation of a PageImage.

Github Gist

<?php 

$page->image->orientation;
$page->image->orientation();

 

Add the ability to get all related pages for Page objects at once

Gets all of the related pages to a page at once by both page reference fields and links in fields. Transparently passes native arguments to Page methods for native behavior

Github Gist

<?php 

$page->allPageReferences();
$page->allPageReferences(true); // Optionally include all pages regardless of status
$page->allPageReferences('your_selector=here', 'field_name'); // Use with native Page::references() and Page::links() arguments

 

Add a saveWithoutHooks() convenience method to Page objects

The number of hooks in my most recent project was... a lot. There were many that hooked into the page save event and a lot of operations that happen in the background where pages needed to be modified and saved quietly to prevent clearing ProCache files or excessive DB operations through chained hooks. Being able to use a method to do this rather than passing options felt more deliberate and clear when working across hundreds of files and in critical areas of very expensive operations.

This method also accepts page save options, but in a way that hooks will always be disabled even if an option is accidentally passed enabling them. Furthermore, it also accepts a string as the first argument that, if debug mode is enabled, will dump a message to the bar via Tracy.

Github Gist

<?php 

// Adding a message can be very helpful during testing, especially when saving a page with/without hooks is conditionally based
// where the result of another operation determines how a page is saved
$page->saveWithoutHooks('updated event sync data hash, saved without hooks');

$page->saveWithoutHooks(['resetTrackChanges' => true]);

$page->saveWithoutHooks('message and options', ['resetTrackChanges' => true]);

 

These are a few that I've used to show some diversity in application. Hooking to ProcessWire events makes it possible to build beyond simple websites and implement truly custom behavior. Hope these may be useful to others. If you have any favorite hooks of your own, have corrections of those I've shared, or improvements, sharing them in the comments would be stellar.

Cheers!

 

 

  • Like 11
  • Thanks 10
Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
 Share

×
×
  • Create New...