NAME

Punk - a MVC web framework

SYNOPSIS

package MyApp;
use Punk;

get  '/'          => 'Web::Book#home';
get  '/books/:id' => 'Web::Book#view';
post '/books'     => 'Web::Book#create';

my $admin = under '/admin' => sub {
    my ($c) = @_;
    return $c->redirect('/') unless $c->req->header('authorization');
    return;
};
$admin->get('/books' => 'Web::Book#admin_list');

static '/static' => 'root/static';
plugin 'RequestId';

1;

# app.psgi
use MyApp;
MyApp->to_app;

GETTING STARTED

punk new MyApp
cd MyApp
plackup app.psgi

punk new writes a running application - routes, a controller, Stencil views, config/punk.yml, a psgi entry point and a test that starts the app and requests a page. Point it at an OpenAPI document and it mounts that too, generating a controller of operation stubs per tag:

punk new MyApp --api ./openapi.json

Once it is running, punk routes prints the compiled table, punk doctor reports the environment and C ABIs, punk config check resolves the configuration and its secrets, and punk dev serves with restart-on-change. punk generate controller|model adds to an existing application, punk test runs its suite, and punk secret mints key material for the session config. See Punk::Generate and Punk::Command.

The generated test drives the app through Punk::Test: an in-process client with a cookie jar and chained assertions, so sessions, CSRF, JSON APIs, server-sent events and websockets are all testable against the same frozen coderef a server would run.

DESCRIPTION

Punk resolves and freezes everything - routes, guard chains, handler coderefs, helpers, mounts - once, at to_app time. Nothing is interpreted per request: dispatch is a hash lookup or a short bucket scan, guards are a frozen array walk, and the handler is a plain coderef call receiving one argument, the Punk::Context.

use Punk turns on strict and warnings, creates the per-application registry, and exports the DSL keywords below into the calling package.

KEYWORDS

get / post / put / patch / del / any

get '/books/:id' => 'Web::Book#view';
any '/ping'      => sub { my ($c) = @_; $c->text('pong') };

A route. The target is a coderef, or 'Controller#method' resolved against MyApp::Controller:: at boot - typos croak before the app serves. :name captures one path segment, *name captures the rest; captures are available as $c->param($name).

A trailing slash on the request is not a different route: once every declared route, API operation and mount has been tried and none matched, GET /account/ is retried as GET /account. Nothing that already matched is affected - a *splat still captures a trailing slash as part of the remainder, and a mounted app still receives the path it was sent, since only it knows whether /docs and /docs/ differ.

Route options

An optional trailing hashref carries route options; unknown keys croak at boot. Scoped verbs ($scope->get(...)) take the same hashref.

post '/upload' => 'Web::File#create', { max_body => 50_000_000 };

Once a route carries options, the whole declaration may be written as one hashref instead, with the handler under cb:

post '/upload' => { cb => 'Web::File#create', max_body => 50_000_000 };

Both forms are supported and produce identical routes; cb takes exactly what the target position takes, a coderef or 'Controller#method'. The options may go in one place or the other, not both, and a hashref with no cb croaks at boot. websocket and sse accept the same form.

  • cb - the handler. Only in the one-hashref form, where it is required.

  • validate - a JSON Schema, or { schema, source, on_invalid } for the longhand, compiled once at to_app and run before the handler. Errors collect into a Result that a bare $c->validate reads; failure answers 400 { errors => [...] }, or the on_invalid target. See Punk::Validate.

  • schema - the schema half of validate, spelled separately.

  • source - what validate reads (the request body by default).

  • on_invalid - a target to run instead of the house 400.

  • compress - 0 opts the route out of response compression. See below.

  • max_body - refuse a request whose CONTENT_LENGTH exceeds this, overriding the application's "max_body". 0 disables the check for this route.

compress => 0 deserves its own note. Punk does not compress - Hyperman does, because compression belongs to the write path - so this is spelled as a plain response header, Content-Encoding: identity, which the server honours and strips. That makes it a contract any PSGI server could adopt rather than a private arrangement, and it is inert on one that has not. There is no compress => 1: compressing is already the server's answer for a route that says nothing.

Reach for it when a response contains a CSRF token or a session identifier and reflects user input - that combination is the BREACH compression side channel. Every major server compresses anyway, because the alternative is worse for everyone; this is the escape hatch for the handful of responses where it matters.

under

my $scope = under '/admin' => $guard;

A guard scope; see Punk::Router::Scope. Guards receive the context; a reference return short-circuits the request, anything else continues. Scopes nest.

websocket

websocket '/chat' => 'WS::Chat#join';
websocket '/feed' => $target, { protocols => ['v1'] };

A WebSocket route. It routes like a GET (upgrade requests are GET) and sits under the same scopes and guards as any other route, so a guard can reject a client with an ordinary HTTP response before the upgrade happens. Once the handshake is validated and answered, the handler is called with the context and the connection:

sub join {
    my ($c, $ws) = @_;
    $ws->on(message => sub { $_[0]->send("you said $_[1]") });
}

It wires the events it wants and returns; the connection then lives on the server's event loop. See Punk::WebSocket for the events and Punk::WebSocket::Room for broadcasting.

Options: protocols (an arrayref of acceptable subprotocols - a client that offers none of them is refused), max_message_size (default 16MB), write_buffer_limit, and blocking.

WebSocket routes need Hyperman 0.11 or later, whose detach hands the socket to the application. On other PSGI servers, blocking => 1 runs the connection inside the handler over psgix.io instead, which works anywhere but pins one worker per connection. Without either, to_app croaks rather than let the app start with routes it cannot serve.

sse

sse '/events' => 'Live#feed';
sse '/events' => $target, { heartbeat => 30 };

A Server-Sent Events route: the handler is called with the context and a stream once Punk has taken the socket over, and pushes text/event-stream events onto it for a browser's EventSource. Fully non-blocking on a Hyperman worker (the stream lives on the loop); portable to any psgi.streaming server; and blocking => 1 streams inside the handler over psgix.io. Options: heartbeat (seconds, default 15), retry (ms), write_buffer_limit, blocking. See Punk::SSE.

sub feed {
    my ($c, $stream) = @_;
    my $tick; $tick = sub {
        return unless $stream->is_open;
        $stream->send({ time => time });
        $c->timer(1)->on_done($tick);
    };
    $tick->();
}

ua

ua timeout => 10;                       # the default agent
ua partner => { timeout => 2 };         # and a named one
ua \%opts;

Options for the outbound user agent behind $c->ua. Every key is handed to Fetch->new as given, so this is Fetch's own constructor surface rather than a second vocabulary for it; the event loop is supplied for you. Also configurable from punk.yml. Optional: an application that never uses it still gets a default agent the first time a handler asks for one.

The agent is one per worker, not one per request, so that its keep-alive pool survives between them. cookie_jar is the exception - a jar belongs to the agent, so cookie_jar => 1 gives each request its own (over the same pool), and cookie_jar => 'shared' is the deliberate opt-out for an upstream that authenticates the application itself. Nothing about the inbound request is forwarded automatically. See Punk::UA.

session

session secret => secret('session_key'), expires => '7d', samesite => 'Lax';

Enable signed cookie sessions: $c->session is then a hashref written back to a HMAC-SHA256-signed cookie when it changes. Source the key from the "secret" system. Options: secret, cookie (default punk.sid), expires, path, domain, secure, httponly (default on), samesite (default Lax). Also configurable from punk.yml. See Punk::Session.

csrf

csrf;
csrf keep => 3, exempt => [ '/hooks/' ];

Single-use CSRF tokens over the session: every unsafe request must carry a live token, and using one spends it. $c->csrf_field is the hidden input for a form, $c->csrf_token the value; the token is also mirrored into a script-readable cookie for fetch. Needs session. See Punk::CSRF.

cors

cors;                                   # a public API: * , no credentials
cors origins => [ 'https://app.example.com' ], credentials => 1,
     paths   => [ '/api' ];

Cross-origin handling, from inside the dispatcher: preflights are answered before routing (so no OPTIONS route is needed) and the headers reach every response, including the 404s and 405s that never build a context. Access-Control-Allow-Methods comes from the router, so it cannot promise a method the application does not serve. See Punk::CORS.

headers

headers;                                # the safe default set
headers 'Content-Security-Policy'   => "default-src 'self'",
        'Strict-Transport-Security' => 'max-age=31536000';
headers 'X-Frame-Options' => undef;     # keep the rest, drop this one

Security response headers on everything the application sends, from the same place CORS decorates: outside the hook chain, so the 404s, 405s and preflight replies carry the policy too. Set-if-absent - a header a handler already set wins. The bare form is X-Content-Type-Options, X-Frame-Options and Referrer-Policy; CSP and HSTS are opt-in by spelling. An under scope can carry its own policy for its prefix: $scope->headers(...). See Punk::Headers.

proxy

proxy;                                  # one proxy in front
proxy trust => 2;                       # a CDN in front of nginx
proxy trust => ['10.0.0.0/8', '172.16.0.0/12'];
proxy trust => 'all';                   # development only
proxy trust => 1, for_header => 'CF-Connecting-IP';

Declares that the application sits behind a reverse proxy, so the real client can be recovered from the forwarded headers.

REMOTE_ADDR is overwritten with the resolved client at the top of the dispatcher, before routing. That is the whole design: rate_limit, $c->block_ip, the access log and $c->req->address all read REMOTE_ADDR and become correct without any of them changing. The address the connection actually came from is kept as $c->env->{'punk.peer_addr'}, and REMOTE_PORT is dropped when the address moved, because it described the proxy's socket.

X-Forwarded-Proto sets psgi.url_scheme (and HTTPS), X-Forwarded-Host sets HTTP_HOST, and X-Forwarded-Port sets SERVER_PORT, all under the same trust decision.

Without this keyword, a limiter behind a proxy is not just approximate - it is a site-wide outage waiting to happen. See "The shared bucket" below.

How trust counts

X-Forwarded-For reads client, proxy1, proxy2, and each hop appends the address it received the connection from. The socket peer is the last proxy and never appears in the header it forwarded. So with trust => N the client sits at index N-1 counting from the right.

Counting from the left is the spoofable version, because the leftmost entry is the one the client writes. With one proxy in front and a client sending X-Forwarded-For: 9.9.9.9, the header arriving here is 9.9.9.9, <real client> - and Punk answers with the real client.

A chain shorter than trust declares is a misconfiguration, or a client that sent nothing; the answer is then the socket peer, never the leftmost entry. An entry that is not a valid address ends the walk the same way - REMOTE_ADDR feeds a shared-memory rate-limit key, so attacker-controlled bytes must never reach it.

trust => \@cidrs walks right to left while each entry is one of the named networks and takes the first one that is not, having first checked that the socket peer is itself trusted. trust => 'all' takes the leftmost entry and is refused outside PUNK_ENV=development: with no proxy actually in front it lets any client claim any address.

Everything is validated at to_app - a mistyped CIDR, a nonsense hop count, an unknown option or a second proxy declaration all croak at boot.

The shared bucket

rate_limit keys on REMOTE_ADDR, and because the counters live in Hyperman's shared arena a limit is exact across the whole worker pool rather than per worker. Behind a proxy without this keyword, REMOTE_ADDR is the proxy for every request, so every client on the internet shares one bucket and a limit => 100 rule throttles the entire site at 100 per window. $c->block_ip, keyed the same way, bans the load balancer.

Reaching for by => 'header:X-Forwarded-For' instead is worse, not better: nothing validates the header, so on an application that is not behind a proxy any client can set it and step into a fresh bucket at will.

What this does not fix

Hyperman's edge denylist drops a connection at accept, before a byte is read, so it cannot see a header and never will. Behind a proxy it can only ever match the proxy's own address. $c->block_ip($client) still writes to the arena, but the ban takes effect at dispatch as a 403 rather than at the edge - the same outcome, at the cost of a request.

$c->block_ip croaks if the address it is about to ban is the one in punk.peer_addr, because banning the proxy takes the site down. Boot-time config cannot catch that, and a silent no-op would leave an operator believing they had banned someone.

auth

auth model => 'User',
     roles => sub { my ($c, $user) = @_; $user->{role} };

The authentication battery: a signed-in identity over the session ($c->login / logout / auth_id / current_user), password hashing in C (Punk::Auth::Password, PBKDF2 over the bundled SHA-256), check_password with a timing-safe dummy verify, and single-use email tokens (issue_token/take_token) on a token_model. Needs session. See Punk::Auth.

auth_guard

my $account = under '/account' => auth_guard;
under '/admin' => auth_guard(role => 'admin');
under '/staff' => auth_guard(role => 'staff', on_denied => '404');

A guard for under: the bare form admits any signed-in user and runs entirely in C. Denial negotiates - a browser is redirected to the login page with a ?to= return-to, an API client gets a 401. Roles rank on a ladder ("admin or better") or match exactly when outside it. See "GUARDS" in Punk::Auth.

max_body

max_body 2_097_152;                          # app-wide, bytes

post '/upload'  => $t, { max_body => 50_000_000 };
post '/webhook' => $t, { max_body => 0 };    # no check on this route

Refuse a request whose CONTENT_LENGTH exceeds a ceiling, with the same 413 an over-large "api" operation gets. A route's own value wins over the app-wide one, and 0 on a route switches the check off there.

The check runs in C after routing and before the hook chain, the guards and the handler, so an oversize request costs no auth lookup, no validation, no body parse and no Perl frame.

This is policy, not memory protection. By the time Punk sees a request, its body is already fully resident in the server's read buffer - the memory was spent before the application was called. What this buys is the parse, the guards, the handler, and an honest answer instead of a mysterious success. The thing that actually bounds a worker's memory is the server's own ceiling, "max_body: the request ceiling" in Hyperman, and this keyword cannot stand in for it. Set both.

A request with no CONTENT_LENGTH is passed through: that is a chunked body, which the server has already decoded and bounded against its own ceiling by the time Punk runs.

static

static '/static' => 'root/static';

Serve files from a directory; see Punk::Static.

If style.css.gz (or .br) sits next to style.css and the client accepts that encoding, the sibling's bytes are served under the original's identity - its Content-Type, its URL, a Content-Encoding and an encoding-tagged ETag of its own. Nothing is compressed per request: the win is a build step's, paid once, so this needs no zlib and costs one stat. A sibling older than its source is ignored rather than served stale, and Vary: Accept-Encoding is on every response from the mount whether or not one was used.

markdown

markdown '/docs' => 'docs', title => 'MyApp Guide';

Serve a nested directory of markdown files as a documentation site, with navigation, per-page contents, syntax highlighting and search. The whole site is rendered at boot and frozen, so a request is a hash lookup; see Punk::Mount::Markdown.

mount

mount '/legacy' => $psgi_app;

Mount any PSGI app under a prefix (longest prefix wins).

api

my $api = api 'openapi.json';
my $v1  = under '/v1' => $guard;
my $api = $v1->api('openapi.json' => { security => { key => $checker } });

Mount an OpenAPI 3.1 document: each operation dispatches to the controller method named after its operationId, with request validation, security-as-guards and per-prefix guards all resolved at boot. Returns the mount. Under a scope it inherits the scope's prefix and guards. See Punk::Mount::OpenAPI.

docs

docs '/docs';
docs '/docs' => $api, { ... };

Serve an API documentation UI (Open::API::UI) for a mounted spec. With one api mount the mount is implied; name it when several are mounted. A docs path the spec already declares croaks at boot.

config

config 'config/punk.yml';
config 'config/punk.yml', env => 'production', secrets => 'strict';

Load YAML configuration and apply it. Blocks that mirror a DSL keyword register for real, so deployment configuration needs no code change:

views:                       # -> views Stencil => {...}
  Stencil:
    template_dir: root/templates
database:                    # -> database dsn => ...
  dsn:      dbi:Pg:dbname=myapp
  password: { $env: DB_PASSWORD }
models:   [ Book ]           # -> model 'Book'
plugins:                     # -> plugin 'RequestId' => {...}
  RequestId: {}
static:                      # -> static '/static' => 'root/static'
  /static: root/static

Everything else in the file is yours, through $app->config.

Applied where the keyword sits, so put it first and the routes after it can rely on what it registered. Layers: punk.yml, then punk.$PUNK_ENV.yml, then the gitignored punk.local.yml.

Secrets never belong in the file. A value written { $env: NAME }, { $file: PATH } or { $exec: [...] } is resolved at boot from outside it; $app->config shows [redacted] in its place and $app->secret('database.password') reaches the real thing. A plaintext value under a secret-shaped key warns by default (secrets => 'strict' refuses to start). See Punk::Config.

YAML parsing is one YAML::XS call per file; it is loaded only when this keyword is used, so an application that declares everything in Perl never touches it.

secret

my $password = secret 'database.password';

A resolved secret, by dotted path. Boot-time; handlers that need one should close over it or reach it through a plugin helper.

views

views Stencil => { template_dir => 'root/templates' };

Register a view engine; the first registered is the default. See Punk::Views.

database / model

database dsn => 'dbi:SQLite:dbname=myapp.db';
model;                    # everything under MyApp::Model::
model 'Book';             # or just the ones named

Model tier configuration; see Punk::Model. database records the backend connection options (a dsn, optional user/password/ attr, or backend => 'Class' to swap the backend); model registers model classes by name, resolved against MyApp::Model:: at boot.

The bare form loads and registers everything under MyApp::Model:: - every .pm in that namespace across @INC, plus any model class already compiled into the symbol table. Naming models normally switches auto-discovery off; the bare form switches it back on, so model; next to model 'Special' registers everything and is harmless. Discovery is also the default when no model keyword appears at all.

Several databases may be configured by giving each a name and an options hashref; a model then names the one it lives in with its own database declaration (see Punk::Model), defaulting to the unnamed one:

database dsn => 'dbi:SQLite:dbname=myapp.db';        # the default
database analytics => { dsn => 'dbi:Pg:dbname=warehouse' };

Every model on one database shares a single connection per worker.

hook

hook before_request  => sub { my ($c) = @_; ...; return };
hook before_dispatch => sub { my ($c) = @_; ...; return };
hook after_dispatch  => sub { my ($c, $resp) = @_; ... };

before_request runs before routing; before_dispatch runs after routing and before guards (in both, a reference return short-circuits); after_dispatch sees the finalized PSGI triplet and may mutate it or return a replacement.

All three take a coderef or a 'Controller#method' target, run in registration order, and stop at the first reference return. A die goes through "on_error", and a returned Future is awaited.

before_request vs before_dispatch

They differ only in when they run, and therefore in what they can see:

  • before_request is the only phase that runs for a request that does not match a route: a 404, a 405, and anything answered by a PSGI or static mount - none of which reach before_dispatch at all.

  • $c->match is empty inside before_request (there is no matched route yet). It is a real hashref with empty captures, so $c->match and $c->param behave rather than croak; it is populated by the time the handler or API operation runs.

  • Both hooks get the same context. A stash written in before_request is there in the handler and in after_dispatch, which is what makes it useful for timing and annotating a request.

The cost of running first is that before_request is ahead of three things that refuse requests:

hook before_request => sub { ... };   # runs even when the request is
                                      # about to be refused by:
csrf;                                 #   the csrf check
rate_limit ...;                       #   the rate limiter
max_body 1_000_000;                   #   the max_body ceiling

For a hook that measures or records - a span, a request id, an access count - that is exactly right: a refused request is still a request, and you want it. For a hook that does work on the client's behalf, it is wrong, and before_dispatch remains the correct phase. (The max_body case costs no memory that was not already spent: the body is resident in the server's buffer before Punk is called at all.)

An application with no before_request hook pays nothing for the phase existing - the chain is omitted from the compiled state entirely, and no context is built before routing.

middleware

middleware sub { my ($app) = @_; sub { my ($env) = @_; ... } };

An outer PSGI wrap, applied at to_app.

on_error

on_error sub { my ($c, $err) = @_; ... };

Runs when a guard or handler dies; a reference return becomes the response, otherwise the 500 {"errors":[...]} default is served.

In the development environment - an opt-in: punk dev, or PUNK_ENV=development, or the config's env; the default is production - that default is a debug response instead: an HTML page with the stack and source snippets for a browser, the same JSON shape plus a trace array for everything else. A handler registered here still runs first and its reference return still wins, in every environment. See Punk::DevError.

Which suggests the branded-page pattern: decline in development so the debug page stays, take over in production -

on_error sub {
    my ($c, $err) = @_;
    return if $c->app->env ne 'production';
    $c->log->error("$err");
    return $c->render('error', {}, status => 500);
};

on_not_found

on_not_found sub {
    my ($c) = @_;
    return $c->render('404', { path => $c->req->path }, status => 404);
};
on_not_found 'Web::Err#not_found';

Runs when no route, mount or API operation matched - the same contract as "on_error": a reference return becomes the response (after hooks run, so sessions and flash work on the page; a returned Punk::Future is awaited), anything else keeps the default 404 {"errors":[...]} byte-identical. A die inside it goes through "on_error". The 405 answer for a known path with the wrong method is deliberately not covered: its Allow header semantics stay.

plugin

plugin 'RequestId';
plugin '+My::Plugin' => { opt => 1 };

Load and register a plugin; see Punk::Plugin.

helper

helper uid => sub { my ($c) = @_; $c->stash->{uid} };

Install a context helper method (usually done from plugins).

Plugins add keywords of their own with $app->install_kw(name => sub {...}); see "KEYWORDS OF YOUR OWN" in Punk::Plugin. They behave exactly like the ones above.

to_app

Compile and freeze everything; returns the PSGI coderef. Callable as MyApp->to_app. Each call builds an independent app from the configuration at that moment.

punk_app

The underlying Punk::App registry (the registrar surface plugins receive).

ASYNC

A handler may hand back a future instead of a response: Punk awaits any future-compatible return (then / on_ready / get). Punk::Future is the native one - $c->promise, $c->timer($secs) and $c->await($f) create and drive it. On a Hyperman worker it runs on the loop and the worker serves other requests while it is pending; anywhere else it blocks. So

get '/slow' => sub {
    my ($c) = @_;
    $c->timer(2)->then(sub { $c->json({ waited => 2 }) });
};

answers two seconds later without pinning a worker.

C ABI

Punk publishes a C ABI, pk_abi.h, installed through ExtUtils::Depends and reached at runtime through Punk::_abi_ptr - the same function-pointer table Punk itself uses to reach Open::API, Hyperman and DBIx::Loop. It exists for the one thing a Perl hook cannot do cheaply: observe every request, on every path, without paying a call_sv per request for the privilege.

#include "pk_abi.h"

static void on_req(pTHX_ SV *c, void *ud) { ... }
static void on_res(pTHX_ SV *c, SV *response, void *ud) { ... }

A->on_request(aTHX_ on_req, NULL);
A->on_response(aTHX_ on_res, NULL);

on_request fires before routing - so before the csrf check, before rate_limit and before the max_body ceiling, the same trade "before_request" makes. on_response fires exactly once per request, on every path: a matched route, an API operation, a mount, a 404, a 405, a 413, and an asynchronous answer, where it fires when the future settles rather than when the handler returned it. Both are handed the same context, so state left in its stash by one is there for the other.

The table also gives a consumer the request's route_pattern_of - the route as declared, "/users/:id" - which is the thing anything grouping by route needs and which nothing outside the router could previously ask for.

on_query (v2) observes statements run by the shipped Punk::Model::DBI backend. Note that there are two database paths here: DBIx::Loop has its own observer, in dbil_abi, and an application using the default model backend generates no DBIx::Loop traffic at all. A consumer wanting to see every query an application makes registers with both. Neither is given the bind values - only the statement text, which carries placeholders exactly where the literal data would have been.

Registration is process-global, not per application, which is the opposite of every other hook here: an app is a compiled artifact and a process may hold several, while an observer is a property of the process. Register at boot; there is no deregistration. Registering nothing costs nothing.

The table only grows at the end, PK_ABI_VERSION bumps on any append, and a consumer checks abi_version before use. Nothing in it mutates a request or a response: "after_dispatch" already does that, in Perl, where a reader can see it.

SEE ALSO

Punk::Test, Punk::Context, Punk::Router::Scope, Punk::Plugin, Punk::CSRF, Punk::CORS, Punk::UA, Punk::Controller, Open::API, Template::Stencil, Hyperman.

AUTHOR

LNATION <email@lnation.org>

BUGS

Please report any bugs or feature requests to bug-punk at rt.cpan.org, or through the web interface at https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Punk. I will be notified, and then you'll automatically be notified of progress on your bug as I make changes.

SUPPORT

You can find documentation for this module with the perldoc command.

perldoc Punk

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)