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.
An optional trailing hashref carries route options; unknown keys croak at boot. The one option today is validate - a JSON Schema (or { schema, source, on_invalid }) compiled once at to_app and run before the handler, collecting errors into a Result a bare $c->validate reads and answering 400 { errors => [...] } (or the on_invalid target) on failure. See Punk::Validate. Scoped verbs ($scope->get(...)) take the same hashref.
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.
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.
static
static '/static' => 'root/static';
Serve files from a directory; see Punk::Static.
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_dispatch => sub { my ($c) = @_; ...; return };
hook after_dispatch => sub { my ($c, $resp) = @_; ... };
before_dispatch runs after routing and before guards (a reference return short-circuits); after_dispatch sees the finalized PSGI triplet and may mutate it or return a replacement.
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.
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)