-
Notifications
You must be signed in to change notification settings - Fork 1
plugin module organization
Plugins start as a single .pm with the lifecycle hooks inline. By the time the plugin handles half a dozen entrypoints, a REST API, scheduled cron, and an audit trail, the file is 2000+ lines and impossible to navigate. The pattern below — a Lib::* namespace for cross-cutting helpers and a Controllers/<Surface>/<Op>.pm namespace for per-op handlers — keeps the main module thin and the test surface narrow.
koha-plugin-staff-roster shipped this restructure across commits 7021e3f..8cdb8a0. Main module shrank 2241 → 1122 lines.
Koha/Plugin/<TLD>/<Org>/<Project>.pm # thin entrypoint: hook stubs + dispatcher maps
Koha/Plugin/<TLD>/<Org>/<Project>/
Lib/
Audit.pm # audit + transaction helpers
DateUtils.pm # week-start, DST-safe date arithmetic
I18N.pm # translator, JSON dictionary loader
Permissions.pm # sub-perm registry + has_perm/gate helpers
Rrule.pm # RRULE parse + apply checks
Schema.pm # install/upgrade/uninstall + migration registry
Visibility.pm # group walk + calendar closure helpers
AdditionalFields.pm # additional_fields load/save/delete/bulk
Controllers/
Tool/
List.pm # op=list
Form.pm # op=add_roster | edit_roster | delete_confirm
Slots.pm # op=manage_slots | view_assignments
Exceptions.pm # op=manage_exceptions
Swaps.pm # op=manage_swaps
SelfService.pm # op=my_shifts | open_shifts
AssignmentController.pm # REST controllers stay one-per-resource
RosterController.pm
StaffController.pm
Two namespaces, two responsibilities:
-
Lib::*— cross-cutting helpers. No CGI, no template, noopknowledge. Pure helpers callable from any controller, any cron, any test. -
Controllers/<Surface>/<Op>.pm— per-op handler + view body. Knows about the dispatcher entry it serves; receives$self, $dbh, $cgi, $template, $messagesand orchestrates theLib::*helpers.
A helper graduates to Lib::* when:
- It's called from more than one place (controllers, cron, tests, REST).
- It owns a single concept (audit, transactions, additional fields, RRULE parsing).
- It can be unit-tested without standing up a full template render.
Anti-patterns that should stay out of Lib::*:
- CGI or template handling — those live in controllers.
- Per-op business rules — those live in the controller for that op.
- Multi-concept catch-all modules (
Lib::Util.pmis a smell).
The lifecycle hooks (install, upgrade, uninstall) usually accumulate version chains:
sub upgrade {
my ($self) = @_;
my $v = $self->retrieve_data('__INSTALLED_VERSION__') // '0.0.0';
if (_version_lt($v, '0.0.2')) { $dbh->do(q{ALTER TABLE ... }); }
if (_version_lt($v, '0.0.3')) { $dbh->do(q{ALTER TABLE ... }); }
# ... grows forever, each branch easy to forget
}Replace with an ordered registry that install and upgrade both walk:
package Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Schema;
my @MIGRATIONS = (
{ version => '0.0.1', up => \&_migrate_initial_schema },
{ version => '0.1.0', up => \&_migrate_swap_request_actor },
# Append at the bottom; never reorder, never edit a shipped one.
);
sub _version_lt {
my ( $a, $b ) = @_;
my @a = split /\./smx, $a;
my @b = split /\./smx, $b;
for my $i ( 0 .. ( $#a > $#b ? $#a : $#b ) ) {
my $av = $a[$i] // 0;
my $bv = $b[$i] // 0;
return 1 if $av < $bv;
return 0 if $av > $bv;
}
return 0;
}
sub _apply_migrations {
my ( $plugin, $dbh ) = @_;
my $current = $plugin->retrieve_data('__SCHEMA_VERSION__') // '0.0.0';
for my $m (@MIGRATIONS) {
next if !_version_lt( $current, $m->{version} );
$m->{up}->($dbh);
$plugin->store_data({ __SCHEMA_VERSION__ => $m->{version} });
$current = $m->{version};
}
return;
}
sub install {
my ($plugin) = @_;
my $dbh = C4::Context->dbh;
_apply_migrations($plugin, $dbh);
Koha::Plugin::...::Lib::Permissions::register($dbh);
_register_notice_templates($dbh);
$plugin->store_data({ __INSTALLED_VERSION__ => $plugin->get_metadata->{version} });
return 1;
}
sub upgrade {
my ($plugin) = @_;
my $dbh = C4::Context->dbh;
_apply_migrations($plugin, $dbh); # same call
Koha::Plugin::...::Lib::Permissions::register($dbh); # idempotent re-register
_register_notice_templates($dbh);
$plugin->store_data({ __INSTALLED_VERSION__ => $plugin->get_metadata->{version} });
return 1;
}Three rules:
- Append-only — never reorder, never edit a migration that shipped. Every migration has run on real installs already; editing it changes history those installs can't replay.
-
Idempotent migrations — write each
upso a re-run is a no-op (useIF NOT EXISTS,ON DUPLICATE KEY UPDATE, etc.). The_version_ltgate is strict-greater-than, but defensive idempotency leaves the door open for repair runs. -
Two version stamps, one schema version —
__SCHEMA_VERSION__(the latest applied migration) and__INSTALLED_VERSION__(the plugin release the schema corresponds to). They diverge intentionally when a release ships no schema change. The migration loop reads__SCHEMA_VERSION__; the rest of the plugin reads__INSTALLED_VERSION__for feature gates.
The main module's lifecycle hooks become one-line wrappers:
# main module — Koha::Plugin::Xyz::Paulderscheid::StaffRoster
use Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Schema;
sub install { return Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Schema::install($_[0]); }
sub upgrade { return Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Schema::upgrade($_[0]); }
sub uninstall { return Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Schema::uninstall($_[0]); }The single tool hook handles many ops via a dispatcher. Hold the maps in the main module so the gate logic and post-dispatch cleanup stay in one place; let each map entry point at a Controllers/Tool/<Op> package:
# main module
use Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Controllers::Tool::List;
use Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Controllers::Tool::Form;
use Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Controllers::Tool::Slots;
# ... etc
my %TOOL_VIEWS = (
list => \&Koha::Plugin::...::Controllers::Tool::List::view_list,
add_roster => \&Koha::Plugin::...::Controllers::Tool::Form::view_roster_form,
edit_roster => \&Koha::Plugin::...::Controllers::Tool::Form::view_roster_form,
delete_confirm => \&Koha::Plugin::...::Controllers::Tool::Form::view_delete_confirm,
manage_slots => \&Koha::Plugin::...::Controllers::Tool::Slots::view_manage_slots,
view_assignments => \&Koha::Plugin::...::Controllers::Tool::Slots::view_assignments,
manage_exceptions => \&Koha::Plugin::...::Controllers::Tool::Exceptions::view_manage_exceptions,
manage_swaps => \&Koha::Plugin::...::Controllers::Tool::Swaps::view_manage_swaps,
);
my %TOOL_ACTIONS = (
'cud-save_roster' => { handler => \&Koha::Plugin::...::Controllers::Tool::Form::save_roster, next => 'list' },
'cud-delete_roster' => { handler => \&Koha::Plugin::...::Controllers::Tool::Form::delete_roster, next => 'list' },
'cud-save_slot' => { handler => \&Koha::Plugin::...::Controllers::Tool::Slots::save_slot, next => 'manage_slots' },
# ... etc
);Each Controllers/Tool/<Op>.pm:
package Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Controllers::Tool::Slots;
use Modern::Perl;
use C4::Context;
use Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Audit;
use Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Permissions;
use Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Rrule;
sub view_manage_slots {
my ( $self, $dbh, $cgi, $template ) = @_;
# ... pull data, set template params ...
}
sub save_slot {
my ( $self, $dbh, $cgi, $messages ) = @_;
return if !Koha::Plugin::...::Lib::Permissions::gate( 'staffroster_manage_rosters', $messages );
# ... save logic, _audit, etc ...
}
1;Convention: each subcontroller exports nothing. Callers reference functions by fully-qualified name. The main module's dispatcher map is the entire public API of the package.
The split affects how tests bind helpers. Two patterns work:
# Test-side: bind by package-qualified name via require
require Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Permissions;
my $register = \&Koha::Plugin::...::Lib::Permissions::register;
$register->($dbh);
# Test-side: bind a coderef once at the top, reuse
use Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Audit qw( txn );
txn(sub { ... });t/00-load.t should pick up every .pm so a missing use line in a fresh module fails fast:
use_ok( 'Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Audit' );
use_ok( 'Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Permissions' );
# ... etc, one per .pm in the treeMid-restructure, the old call shape (_audit('CREATE', $id, $info, $original) in the main module) is still in flight while the controllers move out. Keep a thin shim until every caller is migrated, then drop it:
# main module — TEMPORARY shim during the Lib/* migration
sub _audit {
return Koha::Plugin::Xyz::Paulderscheid::StaffRoster::Lib::Audit::log_action(@_);
}Mark the shim with a comment dating it; remove once git grep '_audit\b' Koha/ is silent. The staff-roster final commit (8cdb8a0) lists every shim it dropped; a clean ledger like that is the marker for "restructure complete."
Three signals:
- The main module crosses 1500 lines.
- A single hook (
tool,report,api_routes) dispatches to more than three op handlers. - Test files duplicate
_audit/_txn/ permission-helper imports across files.
For plugins with one tool op and a single REST endpoint, this is overkill. For plugins growing past their initial scope, the restructure pays off the first time you need to run prove t/some_module.t without booting the whole plugin's dependency graph.
- A plugin scaffold (
koha-plugin add lib,koha-plugin add controller) that lays out the convention from day one. - A
Koha::Plugins::Base::Schemamixin with the migration-registry walker so plugins stop reimplementing_apply_migrations. - A documented dispatcher contract so the per-op map can be declared in YAML alongside permissions, and core wires the gate + view + visibility checks.
Source of truth: docs/ in the main repo. Edits made through the GitHub Wiki UI are overwritten on the next push to main.