How I start a Perl project in 2026

Paul Derscheid — February 7, 2026


Most Perl tutorials stop at use strict; use warnings; and leave the rest to you. Here’s what I actually put in place before writing any code. Not the way — my way. I keep a starter repo that I clone for new projects.

The cpanfile

requires 'Perl::Critic';
requires 'Perl::Critic::Policy::Subroutines::ProhibitCallsToUndeclaredSubs';
requires 'Perl::Tidy';
requires 'App::perlimports';
requires 'Test::More';
requires 'Test::Most';
requires 'Devel::Cover';

Dev dependencies only — the project’s own dependencies get added as you go. Perl::Critic and Perl::Tidy are non-negotiable. App::perlimports keeps your use statements honest. Devel::Cover because writing tests without knowing your coverage is guessing.

Carton

carton install
carton exec -- prove -l t

Carton is Perl’s answer to Bundler/npm. It reads the cpanfile, installs everything into local/, and gives you carton exec to run commands in that isolated environment. No polluting your system Perl, no version conflicts between projects.

Some people use cpanm directly into system Perl. That works until you have two projects that need different versions of the same module. Carton costs you one extra word in front of your commands.

just

Instead of a Makefile or shell scripts, I use just:

perl_env := env_var_or_default("PERL_ENV", "carton")
_carton := if perl_env == "system" { "" } else { "carton exec --" }

install:
    #!/usr/bin/env bash
    if [ "{{perl_env}}" = "system" ]; then
        cpanm --installdeps .
    else
        carton install
    fi

fmt:
    find . -type f \( -name "*.p[lm]" -o -name "*.t" \) \
        -not -path "./local/*" | xargs -n1 perltidy -b -bext="/"

lint:
    find . -type f \( -name "*.p[lm]" -o -name "*.t" \) \
        -not -path "./local/*" | xargs -n1 {{_carton}} perlcritic

test:
    {{_carton}} prove -l t

test-coverage:
    #!/usr/bin/env bash
    {{_carton}} sh -c "PERL5OPT=-MDevel::Cover prove -l t && cover"

check: fmt lint test

watch:
    find . -name "*.p[lm]" -o -name "*.t" | entr -c just test

just fmt formats everything. just lint runs Perl::Critic. just test runs the test suite. just check does all three. just watch reruns tests on file changes using entr.

The PERL_ENV variable lets you switch between Carton and system Perl. Set PERL_ENV=system if you’re in a container or don’t want the isolation.

Perl::Critic at severity 1

severity = 1
theme = pbp || core
verbose = %f: [%p] %m at line %l, column %c.  %e.  (Severity: %s)

Severity 1 is the strictest level. Most people start at 3 or 4 and work their way down. I start at 1 and disable things I disagree with. It’s easier to relax rules you know about than to discover rules you didn’t know existed.

The interesting overrides:

# Signatures look like prototypes to Perl::Critic
[-Subroutines::ProhibitSubroutinePrototypes]

# Enforce Readonly over constant
[ValuesAndExpressions::ProhibitConstantPragma]

# Max complexity per sub
[Subroutines::ProhibitExcessComplexity]
max_mccabe = 10

# Tell Critic about modern OO keywords
[Subroutines::ProhibitCallsToUndeclaredSubs]
exempt_subs = Object::Pad::field Object::Pad::class Future::AsyncAwait::await ...

The ProhibitSubroutinePrototypes disable is necessary if you use subroutine signatures — Critic can’t tell them apart from prototypes. ProhibitConstantPragma enforces Readonly over use constant, which avoids the gotcha where constants aren’t interpolated in strings. The exempt_subs list grows as you adopt more modern Perl modules that export keywords.

perltidy

-l=125  # Max line width is 125 cols
-i=4    # Indent level is 4 cols
-ci=4   # Continuation indent is 4 cols
-b      # Write the file inline
-vt=2   # Maximal vertical tightness
-pt=1   # Medium parenthesis tightness
-bt=1   # Medium brace tightness
-sbt=1  # Medium square bracket tightness
-nsfs   # No space before semicolons
-nolq   # Don't outdent long quoted strings

Based on PBP defaults with a wider line limit. 125 columns is GitHub’s line break point — code that fits in a GitHub PR diff without horizontal scrolling.

The tightness settings are a matter of taste. I like medium tightness everywhere — not so tight that things are hard to read, not so loose that simple expressions span multiple lines.

The important thing isn’t the specific settings. It’s that the settings exist, in a file, committed to the repo. Nobody argues about formatting because perltidy is the authority.

perlimports

libs                = ["lib", "t/lib"]
padding             = true
preserve_duplicates = false
preserve_unused     = false
tidy_whitespace     = true

App::perlimports manages your use statements. It removes unused imports, adds missing ones, and tidies the import lists. preserve_unused = false is the important one — dead imports accumulate fast without it.

VS Code + PerlNavigator

{
    "perlnavigator.perlPath": "carton exec -- perl",
    "perlnavigator.perlcriticEnabled": true,
    "perlnavigator.perlcriticProfile": ".perlcriticrc",
    "perlnavigator.perltidyProfile": ".perltidyrc",
    "perlnavigator.perlimportsProfile": "perlimports.toml",
    "perlnavigator.perlimportsLintEnabled": true,
    "perlnavigator.perlimportsTidyEnabled": true,
    "perlnavigator.includePaths": ["lib", "t", "local/lib/perl5"]
}

PerlNavigator by bscan is the best Perl language server right now. It runs Perl::Critic and perltidy inline, understands Carton’s local/ directory, and provides go-to-definition that actually works. The config points it at all the project-local settings so what you see in your editor matches what CI will enforce.

CI

steps:
  - uses: shogo82148/actions-setup-perl@v1
    with:
      perl-version: '5.36'

  - name: Install dependencies
    run: |
      cpanm -n Carton
      carton install --deployment

  - name: Run format check
    run: carton exec -- just fmt

  - name: Run linter
    run: carton exec -- just lint

  - name: Run tests
    run: carton exec -- just test

GitHub Actions with shogo82148/actions-setup-perl for Perl installation, Carton for dependencies, and the same just commands you run locally. The CI runs fmt, lint, and test — the same just check sequence. If it passes on your machine, it passes in CI.

What’s not here

No Moo, Moose, or Object::Pad in the starter. They’re project-specific — some things need OO, some don’t. No web framework. No database layer. This is just the floor: formatting, linting, testing, dependency isolation, CI. Everything else is a decision for the actual project.

The starter repo is on GitHub. Clone it, delete the template section from the README, and start writing code.

·

< back