---
title: "skill by agenticluke · skilld"
canonical_url: "https://skilld.dev/gh/agenticluke/modern-perl-patterns-plus"
meta:
  description: "Modern Perl 5.36+ patterns for safe, clear, and easy-to-test apps and modules. From agenticluke/modern-perl-patterns-plus."
  "og:description": "Modern Perl 5.36+ patterns for safe, clear, and easy-to-test apps and modules. From agenticluke/modern-perl-patterns-plus."
  "og:title": "skill by agenticluke"
  "twitter:description": "Modern Perl 5.36+ patterns for safe, clear, and easy-to-test apps and modules. From agenticluke/modern-perl-patterns-plus."
  "twitter:title": "skill by agenticluke"
---

`

[All skills](https://skilld.dev/skills)

[![agenticluke avatar](https://skilld.dev/_img/avatar?url=https%3A%2F%2Fgithub.com%2Fagenticluke.png%3Fsize%3D96)](https://skilld.dev/gh/agenticluke)

# **/skill**

[@1fdeb61](https://github.com/agenticluke/modern-perl-patterns-plus/commit/1fdeb6189fdc1fc33136a71339dd9f26ea172606 "Your agent reads SKILL.md at commit 1fdeb61")

by [agenticluke](https://skilld.dev/gh/agenticluke)· [agenticluke](https://skilld.dev/gh/agenticluke)/ [modern-perl-patterns-plus](https://skilld.dev/gh/agenticluke/modern-perl-patterns-plus)

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](https://github.com/agenticluke/modern-perl-patterns-plus/blob/main/skill/SKILL.md "View SKILL.md on GitHub")

## SKILL.md

16.1 KB

**≈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](https://github.com/agenticluke/modern-perl-patterns-plus/blob/main/skill/SKILL.md)

## Third-party checks

No third-party reports yet.

## Provenance

[Signed by skilld at 1fdeb61.](https://github.com/agenticluke/modern-perl-patterns-plus/commit/1fdeb6189fdc1fc33136a71339dd9f26ea172606 "1fdeb6189fdc1fc33136a71339dd9f26ea172606") 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

## Capability

<dl>

<dt>origin</dt>
<dd>ECC</dd>

</dl>

## README badge

![README badge for agenticluke/modern-perl-patterns-plus](https://skilld.dev/b/agenticluke/modern-perl-patterns-plus?theme=light&label=0)

## Related skills

-
-
-
-
-
-