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)