The difference between a plugin you can build on and one you end up forking is its hook surface. A plugin with well-named actions and filters at the right points lets you change behaviour, inject markup, and react to events without editing a single line of its source — which means your customisations survive every update. A plugin with no hooks forces you to fork it, and a fork is a maintenance liability the day the next version ships. This is how hook-driven extensibility works in practice: what actions and filters are, where a gallery plugin exposes them, and how to use them to bend the plugin to a client’s needs without owning its codebase.
It is the extensibility deep-dive of our developer’s guide to building galleries, and the natural sequel to building the block itself — once you can render a gallery, the next question is how to change one you did not write.
Actions vs filters: the two-minute version
WordPress has two kinds of hooks and the distinction is the whole game. An action fires at a moment — “an item was just added to a gallery”, “we are about to render the gallery markup” — and lets you do something: log it, clear a cache, enqueue a script, print extra HTML. Actions return nothing; you hook in for the side effect. A filter passes a value through your function and uses what you return — “here is the default gallery config, change it if you like”, “here is the list of CSS body classes, add yours”. Filters are how you change data the plugin is about to use. The rule of thumb: if you want to react, you want an action; if you want to modify, you want a filter.
Both attach with the same two functions — add_action() and add_filter() — and both take a hook name, your callback, an optional priority (lower runs earlier, default 10), and the number of arguments your callback accepts. The single most common mistake is forgetting that last argument: if a hook passes three values and you only declare one, you silently lose the other two. Always count the parameters the hook actually passes and match them.
Where to put the code
Hook code belongs in one of two places, never in the plugin you are extending. For a single site, a small custom plugin — one PHP file with a plugin header in wp-content/plugins/ — is the right home; it survives theme switches and is easy to deactivate when you are debugging. For agency work where the same customisations follow you across client sites, a shared “must-use” plugin or an internal composer package keeps them versioned. The one place hook code should never live is the theme’s functions.php if there is any chance the theme will change — customisations that vanish on a theme switch are customisations you will rebuild at the worst possible moment.
Do this now. Create a one-file plugin called {client}-gallery-customisations.php in wp-content/plugins/ with a standard plugin header. Activate it. Every hook in this article goes in that file. When you hand the site over, the client gets a single, clearly named plugin that holds all your gallery customisations — and you can deactivate it in one click to prove a bug is yours or the plugin’s, not both tangled together.
Reacting to events with actions
The most useful event actions are the ones that fire when the gallery’s data changes, because that is when you usually need to do something downstream — sync to another system, bust an external cache, send a notification. FotoGrids fires an action every time an item is added to a gallery, passing the attachment ID, the gallery ID, and the item’s metadata:
// Fires after an image is successfully added to a FotoGrids gallery.
// Signature: do_action( 'fotogrids/actions/item/added', $attachment_id, $gallery_id, $meta );
add_action( 'fotogrids/actions/item/added', function ( $attachment_id, $gallery_id, $meta ) {
// Example: purge a page cache for the gallery's page whenever its contents change.
if ( function_exists( 'my_cdn_purge_post' ) ) {
my_cdn_purge_post( $gallery_id );
}
error_log( sprintf( 'Item %d added to gallery %d', $attachment_id, $gallery_id ) );
}, 10, 3 ); // note the 3 — this hook passes three arguments
The 10, 3 at the end is doing real work: priority 10 (the default) and three arguments, matching the three values the hook passes. Drop the 3 and your $gallery_id and $meta arrive as null. The same pattern covers the reorder event, which passes the gallery ID and the new item order — useful if you mirror gallery state into a headless front end or a search index that cares about sequence:
// Signature: do_action( 'fotogrids/actions/gallery/reordered', $gallery_id, $item_order );
add_action( 'fotogrids/actions/gallery/reordered', function ( $gallery_id, $item_order ) {
// $item_order is the array of attachment IDs in their new sequence.
my_search_index_resync( $gallery_id, $item_order );
}, 10, 2 );
The general technique transfers to any well-built plugin: find the action that fires at the moment you care about, match the argument count, and do your side effect there. You are not changing the plugin — you are listening to it.
Changing behaviour with filters
Filters are where extensibility gets genuinely powerful, because they let you change what the plugin does without changing what the plugin is. The most far-reaching one in a gallery plugin is the defaults filter: the config every new gallery starts from. FotoGrids passes its resolved gallery defaults through a filter before any gallery uses them, so you can set agency-wide defaults — a house layout, a default gap, lightbox on — once, and every new gallery on the site inherits them:
// Signature: apply_filters( 'fotogrids/settings/defaults/gallery', $defaults, $is_defaults_page );
add_filter( 'fotogrids/settings/defaults/gallery', function ( $defaults, $is_defaults_page ) {
// Make every new gallery on this site start as a masonry grid with our house gap.
$defaults['layout'] = 'masonry';
$defaults['gap'] = 12;
return $defaults; // a filter MUST return the (possibly changed) value
}, 10, 2 );
The cardinal rule of filters lives in that last line: always return the value. Forget the return and your callback effectively wipes the config to null — one of the most common and most confusing bugs in WordPress, because the symptom (everything breaks) is nowhere near the cause (a missing return three files away). Take the value, change what you need, hand it back unchanged otherwise.
Render-time filters are the other big category. FotoGrids exposes a filter on the responsive breakpoint config the render engine uses, so you can override the tablet and mobile widths globally without touching settings on every gallery — handy when a client’s theme has unusual breakpoints and you want galleries to match:
// Signature: apply_filters( 'fotogrids/render/breakpoint_config', $config );
// $config is a Breakpoint_Config object with tablet_max_width / mobile_max_width.
add_filter( 'fotogrids/render/breakpoint_config', function ( $config ) {
// Force galleries to match the theme's own breakpoints.
return new \FotoGrids\Render\API\Breakpoint_Config(
tablet_max_width: 900,
mobile_max_width: 600,
);
} );
And for the common “add my class to the gallery wrapper” need, there is a body-classes filter that passes the array of classes the renderer is about to apply:
// Signature: apply_filters( 'fotogrids/view/body_classes', $classes, $post );
add_filter( 'fotogrids/view/body_classes', function ( $classes, $post ) {
$classes[] = 'client-brand-gallery';
return $classes;
}, 10, 2 );
Injecting markup at render time
Sometimes you do not want to change the gallery — you want to put something next to it. Render-time actions are for exactly this: they fire at known points in the output and let you echo your own HTML. FotoGrids fires an action immediately before and immediately after the gallery markup, each passing the gallery’s post object, so you can inject a caption, a CTA, a “shop this look” strip, or a schema block right where it belongs:
// Signatures:
// do_action( 'fotogrids/view/before_gallery', $fg_post );
// do_action( 'fotogrids/view/after_gallery', $fg_post );
add_action( 'fotogrids/view/after_gallery', function ( $fg_post ) {
printf(
'<a class="gallery-cta" href="%s">Enquire about this collection</a>',
esc_url( get_permalink( $fg_post ) )
);
} );
This is markup injection done safely: you are adding to the output through a sanctioned tap, not regex-replacing the plugin’s HTML or — worse — filtering the_content and hoping your string match holds. When the plugin updates its markup, your injection still lands in the right place because it is anchored to the hook, not to the HTML.
Gating your code by tier
If your customisation depends on a feature that only exists in a paid tier, do not guess — ask the plugin. FotoGrids exposes a capability filter that answers “can this install use feature X?”, which lets your code degrade gracefully when it runs on a Free site rather than throwing or silently misbehaving:
// Signature: apply_filters( 'fotogrids/features/pro/can_use', false, $feature_name );
// Returns true only if the current licence grants the named feature.
$can_use_filters = apply_filters( 'fotogrids/features/pro/can_use', false, 'gallery_filters' );
if ( $can_use_filters ) {
// Wire up your filter-bar customisation only where the feature actually exists.
add_action( 'fotogrids/view/after_gallery', 'my_custom_filter_bar' );
}
Checking capability through the plugin’s own gate — rather than sniffing for a class or an option — means your code keeps working when the plugin changes how it stores licence state internally. You depend on the question, not the implementation.
How to discover a plugin’s hooks
When the documentation is thin, the source is the documentation. Grep the plugin directory for do_action( and apply_filters( and you get the complete, authoritative list of every hook it fires, with the file and line where each lives — and the code right next to it tells you exactly what arguments each passes. grep -rn "do_action\|apply_filters" wp-content/plugins/the-plugin/ is the fastest way to understand any plugin’s extensibility, and it never lies the way a stale hook reference can. FotoGrids namespaces every hook under fotogrids/, which makes this trivially greppable; the same approach works on any plugin, just with a less tidy result.
Key takeaways
- Actions are for reacting to events; filters are for modifying values. React vs modify is the whole distinction.
- Match the argument count in
add_action/add_filter, and alwaysreturnthe value from a filter — the two most common hook bugs. - Hook code lives in a small custom plugin, never in the plugin you are extending or in a theme that might change.
- Inject markup through render-time actions, not regex on the output — your code survives the plugin’s next markup change.
- Grep the plugin source for
do_action/apply_filtersto find every hook authoritatively.
What is the difference between an action and a filter in WordPress?
An action fires at a moment and lets you do something with no return value — log, enqueue a script, print markup. A filter passes a value through your function and uses what you return, so it is how you modify data the plugin is about to use. Rule of thumb: react with an action, modify with a filter.
Why does my filter callback break the gallery?
Almost always because it does not return a value. A filter must return the (possibly modified) value it was given; if you forget the return, you hand back null and wipe whatever the plugin expected. Take the value, change what you need, and return it — unchanged if you are not modifying it.
How do I find every hook a plugin exposes?
Grep the plugin’s source for do_action and apply_filters: grep -rn "do_action\|apply_filters" wp-content/plugins/the-plugin/. That returns the authoritative list with file and line, and the surrounding code shows exactly which arguments each hook passes — more reliable than any hook reference, which can fall out of date.