Modern Perl Patterns
Original work by ECC. Credit to the author is required and valued.
Use these patterns to write safe and clear Perl 5.36+ code. Keep the app's Perl version, tools, and deploy needs in mind.
When to Use This Skill
Use this skill when you:
- Write new Perl code or modules.
- Review Perl code.
- Update old Perl code.
- Plan a Perl app.
- Move code from Perl older than 5.36.
- Fix unsafe file, data, or error handling.
First Checks
Before you change code:
- Check the Perl version in
cpanfile,Makefile.PL,Build.PL, CI files, and deploy files. - Check which modules are already used.
- Run the current tests.
- Keep public APIs the same unless the task asks for a break.
- Do not use a feature that the target Perl cannot run.
- Do not add a CPAN module when a core module is enough.
- Do not hide errors unless the caller can safely recover.
If the app must run on Perl older than 5.36, use the newest safe form that version supports. Do not add use v5.36 until the app's minimum version is raised.
Core Rules
Use v5.36
For code that needs Perl 5.36 or newer, start with:
use v5.36;This turns on strict checks, warnings, say, and stable sub signatures.
use v5.36;
sub greet($name) {
say "Hello, $name!";
}Do not keep old setup code when use v5.36 can replace it:
use strict;
use warnings;
use feature 'say', 'signatures';
no warnings 'experimental::signatures';A module should also state its package:
package MyApp::Greeter;
use v5.36;
sub greet($name) {
return "Hello, $name!";
}
1;Use Sub Signatures
Use signatures to show which args are needed.
use v5.36;
sub connect_db($host, $port = 5432, $timeout = 30) {
return DBI->connect(
"dbi:Pg:host=$host;port=$port",
undef,
undef,
{
RaiseError => 1,
PrintError => 0,
},
);
}
sub log_message($level, @parts) {
say "[$level] " . join ' ', @parts;
}Defaults only apply when an arg is not passed. An explicit undef stays undef.
sub label($text = 'none') {
return $text;
}
label(); # "none"
label(undef); # undefUse //= inside the sub if undef should also use the default:
sub label($text = undef) {
$text //= 'none';
return $text;
}Avoid a long list of ordered args. Use named args for many options:
sub connect_db(%args) {
my $host = $args{host} // die "host is required\n";
my $port = $args{port} // 5432;
# ...
}Know List and Scalar Context
The same value may act in two ways based on context.
use v5.36;
my @items = (1, 2, 3, 4, 5);
my @copy = @items; # All items
my $count = @items; # Item count
say scalar @items; # Item countDo not make a sub return very different data in list and scalar context unless the API clearly says so. Such code is hard to use.
Use Postfix Dereferencing
Use postfix forms when they make nested data easier to read.
use v5.36;
my $data = {
users => [
{ name => 'Alice', roles => ['admin', 'user'] },
{ name => 'Bob', roles => ['user'] },
],
};
my @users = $data->{users}->@*;
my @roles = $data->{users}[0]{roles}->@*;
my %first = $data->{users}[0]->%*;Do not dereference a value until you know it has the right type.
my @roles =
ref($data->{users}) eq 'ARRAY'
&& ref($data->{users}[0]{roles}) eq 'ARRAY'
? $data->{users}[0]{roles}->@*
: ();Use the isa Operator
Use isa for object checks on Perl 5.32+.
use v5.36;
if ($obj isa 'MyApp::User') {
$obj->save;
}The left side may be undef. The check will be false. Use ref($value) eq 'HASH' or a type tool for plain refs. isa is for objects and class names.
Error Handling
Use eval Carefully
Keep all work that may fail inside the eval. Save $@ right away.
use v5.36;
use JSON::MaybeXS qw(decode_json);
use Path::Tiny qw(path);
sub parse_config($path) {
my $config = eval {
my $text = path($path)->slurp_utf8;
decode_json($text);
};
my $error = $@;
die "Could not read config '$path': $error" if $error;
return $config;
}Do not run other code before copying $@. Other code may change it.
Add a newline to text passed to die when you do not want Perl to add its own file and line text:
die "User $id was not found\n";Do not put passwords, tokens, or full private data in an error message.
Use Try::Tiny When It Is Already a Dependency
use v5.36;
use Try::Tiny;
sub fetch_user($db, $id) {
my $user;
try {
$user = $db->resultset('User')->find($id)
// die "User $id was not found\n";
}
catch {
my $error = $_;
warn "Could not fetch user $id: $error";
};
return $user;
}Assign the result outside try when flow is not clear. Do not use return inside a try block and expect it to return from the outer sub.
Catch an error only when you can handle it. Otherwise, add useful details and throw it again.
Native try and catch
Native try and catch need Perl 5.40+.
use v5.40;
sub divide($x, $y) {
try {
die "Cannot divide by zero\n" if $y == 0;
return $x / $y;
}
catch ($error) {
warn "Divide failed: $error";
return;
}
}Do not use this form in a Perl 5.36 project.
Object-Oriented Code with Moo
Use Moo for small, clear classes. Use Moose only when the app needs its larger type and meta system.
package MyApp::User;
use v5.36;
use Moo;
use Types::Standard qw(ArrayRef Int Str);
use namespace::autoclean;
has name => (
is => 'ro',
isa => Str,
required => 1,
);
has email => (
is => 'ro',
isa => Str,
required => 1,
);
has age => (
is => 'ro',
isa => Int,
default => sub { 0 },
);
has roles => (
is => 'ro',
isa => ArrayRef[Str],
default => sub { [] },
);
sub is_admin($self) {
return scalar grep { $_ eq 'admin' } $self->roles->@*;
}
sub greet($self) {
return "Hello, I'm " . $self->name;
}
1;Use a sub for array and hash defaults. This gives each object its own value.
default => sub { [] }; # Good
default => []; # Bad: objects may share one arrayUse the class like this:
use v5.36;
use MyApp::User;
my $user = MyApp::User->new(
name => 'Alice',
email => 'alice@example.com',
roles => ['admin', 'user'],
);
say $user->greet;Avoid raw blessed hashes for new code. They lack clear checks and safe access methods.
Moo Roles
Use a role for shared acts that fit more than one class.
package MyApp::Role::Serializable;
use v5.36;
use Moo::Role;
use JSON::MaybeXS qw(encode_json);
requires 'TO_HASH';
sub to_json($self) {
return encode_json($self->TO_HASH);
}
1;package MyApp::User;
use v5.36;
use Moo;
with 'MyApp::Role::Serializable';
has name => (is => 'ro', required => 1);
has email => (is => 'ro', required => 1);
sub TO_HASH($self) {
return {
name => $self->name,
email => $self->email,
};
}
1;Do not put secrets in TO_HASH if the data may be logged or sent out.
Native class
The native class feature is experimental in Perl 5.38. Use it only when the project accepts that risk.
use v5.38;
use feature 'class';
no warnings 'experimental::class';
class Point {
field $x :param;
field $y :param;
method magnitude() {
return sqrt($x**2 + $y**2);
}
}
my $point = Point->new(x => 3, y => 4);
say $point->magnitude;Prefer Moo when the app must stay on Perl 5.36 or needs a stable class tool.
Regular Expressions
Use Named Captures and /x
Use names when a match has more than one part.
use v5.36;
my $log_re = qr{
^
(?<timestamp> \d{4}-\d{2}-\d{2} \s \d{2}:\d{2}:\d{2} )
\s+
\[ (?<level> \w+) \]
\s+
(?<message> .+)
$
}x;
if ($line =~ $log_re) {
say "Time: $+{timestamp}";
say "Level: $+{level}";
say "Message: $+{message}";
}With /x, plain spaces and comments in the pattern are ignored. Use \s, [ ], or \x20 when a real space is needed.
Do not use a regex to parse a full data format when a parser exists. Use a JSON, CSV, XML, HTML, or date parser as needed.
Reuse Fixed Patterns
use v5.36;
my $email_re = qr{
\A
[A-Za-z0-9._%+-]+
\@
[A-Za-z0-9.-]+
\.
[A-Za-z]{2,}
\z
}x;
sub valid_emails(@emails) {
return grep { defined($_) && $_ =~ $email_re } @emails;
}This is only a simple check. It does not cover every valid email address. Use a full email tool when exact checks matter.
Use \A and \z for the start and end of the whole string. ^ and $ can act on lines.
Never place raw user text into a regex. Quote it first:
my $safe = quotemeta $user_text;
if ($text =~ /$safe/) {
# Found the exact text.
}Data Structures
Use refs for nested data.
use v5.36;
my $config = {
database => {
host => 'localhost',
port => 5432,
options => ['utf8', 'sslmode=require'],
},
};A long chain is not always safe. It may warn, fail, or change data through auto-vivification. Check each level:
my $port;
if (
ref($config) eq 'HASH'
&& ref($config->{database}) eq 'HASH'
) {
$port = $config->{database}{port};
}Use exists when undef is a valid stored value:
if (exists $config->{database}{port}) {
say "The port key is present";
}Use slices for a small set of values:
my %subset;
@subset{qw(host port)}
= $config->{database}->@{qw(host port)};
my @first_two = $config->{database}{options}->@[0, 1];The multi-value for form is experimental in Perl 5.36:
use v5.36;
use feature 'for_list';
no warnings 'experimental::for_list';
for my ($key, $value) ($config->%*) {
say "$key => $value";
}Use a normal key loop when experimental features are not allowed:
for my $key (sort keys $config->%*) {
my $value = $config->{$key};
say "$key => $value";
}Sort keys when stable output matters.
File Input and Output
Use Three-Arg open
Use a lexical file handle, a fixed mode, and an encoding.
use v5.36;
use autodie;
sub read_file($path) {
open my $fh, '<:encoding(UTF-8)', $path;
local $/;
my $content = <$fh>;
close $fh;
return $content;
}Never mix user input into the mode string:
open FH, $path; # Bad
open FH, "< $path"; # BadUse raw mode for bytes:
open my $fh, '<:raw', $path;For a large file, read one line at a time:
open my $fh, '<:encoding(UTF-8)', $path;
while (my $line = <$fh>) {
chomp $line;
process_line($line);
}When replacing an important file, write a temp file in the same folder and then rename it. This lowers the risk of a half-written file.
Check that a path stays inside the allowed folder before reading or writing user-given paths.
Use Path::Tiny When Available
use v5.36;
use Path::Tiny qw(path);
my $file = path('config', 'app.json');
my $content = $file->slurp_utf8;
$file->spew_utf8($new_content);
for my $child (path('src')->children(qr/\.pl\z/)) {
say $child->basename;
}Path::Tiny is not a core module. Add it to the app's dependency file if it is new.
Module Layout
A common layout is:
MyApp/
โโโ lib/
โ โโโ MyApp/
โ โโโ App.pm
โ โโโ Config.pm
โ โโโ DB.pm
โ โโโ Util.pm
โโโ bin/
โ โโโ myapp
โโโ t/
โ โโโ 00-load.t
โ โโโ unit/
โ โโโ integration/
โโโ cpanfile
โโโ Makefile.PL
โโโ .perlcriticrcKeep app code in lib/. Keep command files in bin/. Keep tests in t/.
End each module with a true value:
1;Export Only on Request
Do not export names by default unless the module is built for that style.
package MyApp::Util;
use v5.36;
use Exporter 'import';
our @EXPORT_OK = qw(trim);
our %EXPORT_TAGS = (all => \@EXPORT_OK);
sub trim($text) {
return $text =~ s/\A\s+|\s+\z//gr;
}
1;Use it like this:
use MyApp::Util qw(trim);
my $name = trim($raw_name);Tests
Every bug fix should get a test when possible.
Use core Test::More for basic tests:
use v5.36;
use Test::More;
use MyApp::Util qw(trim);
is trim(' Alice '), 'Alice', 'trim removes edge spaces';
is trim(''), '', 'trim keeps an empty string';
done_testing;Test these cases when they apply:
- Normal input.
- Empty input.
undef.- Zero and negative numbers.
- Missing files.
- Bad UTF-8.
- Failed network or data calls.
- Very large input.
- Repeated calls.
- Secret data in errors.
- Paths with spaces.
- Values that look like false, such as
0and"0".
Run tests with:
prove -lr tFor modules that need local Carton deps:
carton exec -- prove -lr tTools
.perltidyrc
-i=4
-l=100
-ci=4
-ce
-bar
-nolqRun:
perltidy -b lib/MyApp/App.pmReview the diff after format work. Do not format unrelated files.
.perlcriticrc
severity = 3
theme = core + pbp + security
[InputOutput::RequireCheckedSyscalls]
functions = :builtins
exclude_functions = say print
[Subroutines::ProhibitExplicitReturnUndef]
severity = 4
[ValuesAndExpressions::ProhibitMagicNumbers]
allowed_values = 0 1 2 -1Rules are guides. A project may have good reasons to change them. Add a short note when a rule must be turned off.
Run:
perlcritic lib binDependencies with cpanfile and Carton
requires 'perl', '5.036000';
requires 'Moo';
requires 'Types::Standard';
requires 'JSON::MaybeXS';
requires 'Path::Tiny';
on 'test' => sub {
requires 'Test::More';
};Install and run:
cpanm App::cpanminus Carton
carton install
carton exec -- perl bin/myapp
carton exec -- prove -lr tPin or lock deps with the tool the project already uses. Do not change dependency tools without a clear need.
Concrete Example
This small module reads a JSON settings file and checks its shape.
package MyApp::Config;
use v5.36;
use JSON::MaybeXS qw(decode_json);
use Path::Tiny qw(path);
sub load_config($file) {
die "Config path is required\n"
if !defined($file) || $file eq '';
my $config = eval {
my $text = path($file)->slurp_utf8;
decode_json($text);
};
my $error = $@;
die "Could not load config '$file': $error" if $error;
die "Config root must be an object\n"
if ref($config) ne 'HASH';
die "database must be an object\n"
if ref($config->{database}) ne 'HASH';
my $host = $config->{database}{host};
die "database.host is required\n"
if !defined($host) || $host eq '';
my $port = $config->{database}{port} // 5432;
die "database.port must be a whole number\n"
if $port !~ /\A[0-9]+\z/;
return {
database => {
host => $host,
port => 0 + $port,
},
};
}
1;Test it:
use v5.36;
use Test::More;
use File::Temp qw(tempfile);
use MyApp::Config;
my ($fh, $file) = tempfile();
print {$fh} <<'JSON';
{"database":{"host":"localhost","port":5432}}
JSON
close $fh;
my $config = MyApp::Config::load_config($file);
is $config->{database}{host}, 'localhost', 'reads host';
is $config->{database}{port}, 5432, 'reads port';
done_testing;Review List
Before you finish:
- Confirm the target Perl version.
- Use
use v5.36only when supported. - Use signatures for new subs.
- Keep errors clear and free of secrets.
- Check refs before deep access.
- Use three-arg
open. - Use fixed file modes.
- Use named regex parts for complex matches.
- Quote user text used in a regex.
- Give each object its own array and hash defaults.
- List all non-core modules as deps.
- Add or update tests.
- Run
prove -lr t. - Run the project formatter and checks.
- Keep the public API stable unless a break was asked for.