NAME

Punk - a MVC web framework

SYNOPSIS

package MyApp;
use Punk;

plugin 'RequestId';

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';

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, source and on_invalid are keys of that longhand hashref, not route options in their own right - { validate => { schema => ..., source => 'params' } }, never { schema => ..., source => 'params' }. A route option the list below does not name croaks at boot.

  • 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.

  • sitemap - 0 keeps the route out of the generated sitemap.xml; 1 puts it in despite a guard the plugin would otherwise have excluded it for. Inert unless Punk::Plugin::Sitemap is registered.

  • etag - conditional GET. A coderef returns a validator the application knows cheaply, and an unchanged one answers 304 without running the handler; 1 hashes the rendered body instead, which saves the wire but not the server. Inert unless Punk::Plugin::ConditionalGet is registered.

  • idempotent - honour an Idempotency-Key on this route, so a client's retry replays the first response instead of doing the work twice. Unsafe methods only. Inert unless Punk::Plugin::Idempotency is registered.

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->();
}

cache

cache 'file', dir => '/var/cache/myapp', max_bytes => '512M';
cache sessions => { backend => 'memory', max_bytes => '64M' };

my $html = $c->cache->compute("profile:$id", 300, sub { ... });
$c->cache('sessions')->set($sid => $blob, 3600);

A key/value cache with expiry and compute-if-missing. compute is the method that matters: get, and on a miss run the code, store the result and return it.

A name with a hashref declares a named store - a session cache and a page cache want different budgets, and sharing one means the big cold thing evicts the small hot thing.

file is the default backend, and the arithmetic is why: an in-memory store lives in one process, so workers => 8 with a 512M cap is four gigabytes of RSS with every worker caching the same things separately. The filesystem is already shared, so a file store is one copy for the pool and survives a restart.

Everything is validated at to_app - an unknown backend, a max_bytes that does not parse, an unwritable directory - because a cache that fails on its first miss fails at three in the morning.

See Punk::Cache.

publish / subscribe

$c->publish('cache:bust' => $key);

# at boot, not per request
$app->subscribe('cache:bust' => sub {
    my ($topic, $payload) = @_;
    delete $CACHE{$payload};
});

The cross-worker message bus, for the things one worker learns and the others need: a cache key changing, a config being re-read, a presence update, an event to push down every open stream.

A prefork server makes anything held in a worker a lie about the pool. That is the fault Punk::WebSocket::Room used to have - a broadcast reached the quarter of a room that happened to share a worker with the sender, succeeded, and returned a plausible number. Rooms go over this now, and so can anything else.

Two delivery modes

Without a group, every worker's subscriber sees every message. That is fanout, and it is what pushing an event to every connected client wants.

$app->subscribe('news' => sub { $_->send($_[1]) for @streams });

With group => $name, exactly one member of that group sees each message - work spread across the pool. There is no scheduler: a worker that is busy is not there to claim, so the free ones take the traffic.

$app->subscribe('thumbnails' => \&resize, group => 'workers');

Register at boot, not per request

A subscription made inside a request lands in one worker and lasts as long as that process - which is the same mistake the bus exists to fix. Register where the application is built, or from Hyperman's on_worker_start.

What publish tells you

 1   on the ring: every worker in the pool will see it
 0   local only - there is no pool, so nobody else will
-1   refused: too big for a bus slot

Three outcomes rather than true or false, because "the pool got it" and "only I got it" are different facts, and an application that cannot tell them apart cannot work out why the other workers stayed quiet.

0 is an ordinary answer, not a failure: under a server that is not Hyperman, on Windows, or on a compiler without the atomics the shared ring needs, there is no pool and a message reaches this process alone. That is what the behaviour was before the bus existed.

What it is not

Delivery is at-most-once and nothing survives a restart. A worker that takes a message and then dies loses it, and the loss is counted rather than retried. If losing it matters - an email, a payment, an upload somebody paid to have resized - this is the wrong tool and Punk::Queue is the right one: durable, at-least-once, and still there after a restart.

The bus is for what is worth microseconds and not worth a database.

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';
session secret => secret('session_key'), store => 'cache';   # server-side

Enable sessions: $c->session is then a hashref written back when it changes - to a HMAC-SHA256-signed cookie, or to a store if you name one. Source the key from the "secret" system. Options: secret, cookie (default punk.sid), expires, path, domain, secure, httponly (default on), samesite (default Lax), and store with its sliding, tier and allow_unshared. An option it does not understand is a boot croak. Also configurable from punk.yml.

store puts the payload on any Punk::Cache backend and leaves an opaque id in the cookie, which takes the ~4KB ceiling away and makes $c->session_expire a revocation rather than a request to the browser. See Punk::Session, and Punk::Session::Store for the store half.

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.

A static Content-Security-Policy belongs here. The policy that actually stops cross-site scripting is script-src 'nonce-...', and a nonce is per request - see Punk::Plugin::CSP, which mints one, splices it into the policy, and threads it into your templates.

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.

host

host 'https://example.com';

The application's canonical origin, declared once. Anything that needs an absolute URL for the application defaults to this instead of asking for its own copy, and an explicit option on the plugin still wins - Punk::Plugin::Sitemap's base is the first consumer, so

host 'https://example.com';
plugin 'Sitemap';

is the whole sitemap configuration.

The value must be an absolute http:// or https:// origin. A path is allowed, for an application deployed under a prefix, and trailing slashes are trimmed so a consumer joining a rooted path onto it produces one slash; a query, a fragment, whitespace or a backslash croaks at the keyword. Declared twice, the last declaration wins.

This is configuration, deliberately. The tempting alternative - reading the request's Host header - hands every consumer attacker-supplied bytes, which is exactly why those plugins refuse to guess. Also configurable from punk.yml. With no argument it reads the stored value back, which is how a plugin reaches it: $app->host.

Several hosts: the allowlist

host 'https://example.com', allow => [ '*.example.com', 'shop.tld' ];

One application serving several tenants by Host header has an origin per request, and the request's Host is attacker-supplied. allow names the hosts that may stand in for the canonical one, and $c->origin is then the request's scheme and host only when that host is the canonical one or matches an entry - the canonical origin otherwise, and never the raw header. $c->host_allowed says which of those happened, for an application that would rather refuse an unknown host than answer for it.

An entry is a hostname, optionally with a :port, or a leading *. for every host under a suffix; anything else croaks at the keyword. Matching is case-insensitive and ignores the request's port unless the entry names one. The canonical host needs no entry.

Punk::Plugin::Sitemap is the first consumer: an allowlisted host is handed a sitemap and robots.txt naming itself, rendered from the same route table. In punk.yml the block becomes a mapping:

host:
  origin: https://example.com
  allow:  [ '*.example.com', shop.tld ]

static

static '/static' => 'root/static';
static '/static' => 'root/static', max_age => 3600;
static '/static' => 'root/static', fingerprint => 1;

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

A static file carries ETag and Last-Modified but no freshness lifetime, so a browser revalidates it on every page load. max_age (or a verbatim cache_control) gives a plain URL one.

fingerprint asks for content-addressed URLs instead: $c->asset('/static/app.css') returns /static/app.9f3a1c2b0d4e5f60.css, which serves with a year and immutable - checked against the file's current digest first, so a URL from an older deploy revalidates rather than lying. It is opt-in because it changes what a path means. Templates on the shipped Stencil engine reach asset as a filter: {% "/static/app.css" | asset %}.

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.

favicon

favicon 'root/static/favicon.ico';
favicon 'root/static/favicon.ico', max_age => 3600;

Serve GET /favicon.ico from this file. A browser and a search engine's favicon crawler both request it at the site ROOT, where a /static mount does not answer - without a root route the request is a 404 and search results fall back to the generic globe. This keyword is that route, replacing the send_file handler every application was writing by hand.

The bytes are read once, at to_app, and served from memory with a Cache-Control (public, max-age=86400 unless max_age says otherwise) and a strong ETag; a request carrying the tag back is answered 304 without a body. A file that cannot be read croaks at boot rather than 404ing for as long as nobody notices, and punk dev picks up a replaced icon on its restart. The content type follows the file's extension, so a .png or .svg serves as itself.

The route stays out of Punk::Plugin::Sitemap's document, and it is a route like any other - an application adopting the keyword must delete its hand-rolled get '/favicon.ico', or boot croaks a duplicate. Also configurable from punk.yml: a path, or a mapping with path and max_age.

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: { header: X-Request-Id }
static:                      # -> static '/static' => 'root/static'
  /static: root/static
host: https://myapp.example  # -> host 'https://myapp.example'
favicon: root/static/favicon.ico   # -> favicon '...'

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.

upload_dir

upload_dir '/var/lib/myapp/incoming';

Where a large multipart/form-data part is written while the request runs. Defaults to TMPDIR, else /tmp.

Worth naming, for two reasons that are not obvious. It decides the filesystem, and that decides whether $upload->save is a rename or another whole copy of a large file. And it decides what shares a filesystem with attacker-controlled bytes.

See Punk::Upload.

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: "hook" 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)