All skills

Modern Perl 5.36+ patterns for safe, clear, and easy-to-test apps and modules.

  • 1 file
  • 16.1 KB
  • Updated 2 weeks ago
  • GitHub

Use this Skill: https://skilld.dev/gh/agenticluke/modern-perl-patterns-plus/skill

This session only. Nothing lands on disk.

SKILL.md

โ‰ˆ21 tokens always: the name and description. โ‰ˆ4.1k when used: this file.

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:

  1. Check the Perl version in cpanfile, Makefile.PL, Build.PL, CI files, and deploy files.
  2. Check which modules are already used.
  3. Run the current tests.
  4. Keep public APIs the same unless the task asks for a break.
  5. Do not use a feature that the target Perl cannot run.
  6. Do not add a CPAN module when a core module is enough.
  7. 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);  # undef

Use //= 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 count

Do 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 array

Use 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";   # Bad

Use 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
โ””โ”€โ”€ .perlcriticrc

Keep 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 0 and "0".

Run tests with:

prove -lr t

For modules that need local Carton deps:

carton exec -- prove -lr t

Tools

.perltidyrc

-i=4
-l=100
-ci=4
-ce
-bar
-nolq

Run:

perltidy -b lib/MyApp/App.pm

Review 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 -1

Rules 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 bin

Dependencies 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 t

Pin 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.36 only 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.

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at 1fdeb61. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 weeks ago
origin
ECC

README badge

README badge for agenticluke/modern-perl-patterns-plus