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. See Punk::Generate and Punk::Command.
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).
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.
static
static '/static' => 'root/static';
Serve files from a directory; see Punk::Static.
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 'Book';
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.
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.
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).
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::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)