NAME
PAGI::Tools::Tutorial - Optional convenience helpers for PAGI applications
DESCRIPTION
PAGI itself is a protocol; see PAGI::Tutorial for the protocol tutorial. The PAGI-Tools distribution adds optional convenience helpers built on top of that protocol: middleware, request/response sugar, WebSocket and SSE helpers, routers, and ready-made applications. None of these are required to write a complete PAGI application; they exist to save you boilerplate when you want it, and every one of them is an ordinary PAGI application or a plain wrapper around the $scope/$receive/$send protocol. This guide covers them.
PART 1: HELLO WORLD
A PAGI application is an async sub that receives three arguments: $scope (the request metadata), $receive (pull request-body events), and $send (push response events). At the raw protocol level, "hello world" emits two events — the response start (status and headers) and the body:
use Future::AsyncAwait;
my $app = async sub {
my ($scope, $receive, $send) = @_;
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({
type => 'http.response.body',
body => 'Hello, world!',
});
};
That is a complete, server-ready application — no toolkit required. The helpers in this distribution simply make the common cases shorter. Here is the same response built as a PAGI::Response value:
use Future::AsyncAwait;
use PAGI::Response;
my $app = async sub {
my ($scope, $receive, $send) = @_;
await PAGI::Response->text('Hello, world!')->respond($send);
};
PAGI::Response assembles the two events for you, and respond sends them. Returning JSON is just as short:
await PAGI::Response->json({ hello => 'world' })->respond($send);
From here the tutorial builds up: routing requests to handlers (PART 2), reading form and JSON input (PART 3), the full response builder (PART 4), real-time and streaming (PART 5), middleware (PART 6), and composing larger applications (PART 7).
PART 2: ROUTING
2.1 PAGI::App::Router - Basic Routing
PAGI::App::Router provides lightweight functional routing:
use PAGI::App::Router;
use PAGI::Response;
my $router = PAGI::App::Router->new;
# Mount a static response value — dispatch sends it automatically
$router->mount('/health' => PAGI::Response->json({ ok => \1 }));
# HTTP routes
$router->get('/' => async sub {
my ($scope, $receive, $send) = @_;
my $res = PAGI::Response->new($scope);
await $res->text('Home')->respond($send);
});
$router->post('/users' => async sub {
my ($scope, $receive, $send) = @_;
my $res = PAGI::Response->new($scope);
await $res->json({ created => 1 }, status => 201)->respond($send);
});
# Path parameters
$router->get('/users/:id' => async sub {
my ($scope, $receive, $send) = @_;
my $req = PAGI::Request->new($scope, $receive);
my $res = $req->response;
my $id = $req->path_param('id');
await $res->json({ id => $id })->respond($send);
});
# WebSocket and SSE handlers drive $send imperatively — no return value
$router->websocket('/ws' => async sub { ... });
$router->sse('/events' => async sub { ... });
$router->to_app;
For advanced routing patterns (nested routers, route-level middleware, class-based routing), see PAGI::Tools::Cookbook.
PART 3: HANDLING REQUESTS
Once a request is routed to a handler, PAGI::Request turns the raw $scope and $receive into a convenient object: query and path parameters, headers, cookies, and — the focus of this part — form bodies, JSON bodies, and file uploads. It handles UTF-8 decoding and body parsing for you.
3.1 PAGI::Request - Request Parsing
PAGI::Request parses HTTP requests and provides convenient accessors for headers, query parameters, cookies, and request bodies.
Creating a Request
my $req = PAGI::Request->new($scope, $receive);
my $res = $req->response;
await $res->text("You requested: " . $req->path)->respond($send);
Basic Properties
$req->method # HTTP method (GET, POST, etc.)
$req->path # URL path (decoded UTF-8)
$req->query_string # Query string (raw bytes)
$req->scheme # 'http' or 'https'
$req->host # Host header value
Headers
# Get single header (case-insensitive)
my $user_agent = $req->header('user-agent') // 'Unknown';
# Get content type
my $ct = $req->content_type;
# Get bearer token from Authorization: Bearer <token>
my $token = $req->bearer_token;
Query Parameters
# Get single parameter
my $page = $req->query_param('page') // '1';
# Get all parameters as Hash::MultiValue
my $params = $req->query_params;
my @tags = $params->get_all('tag'); # For ?tag=foo&tag=bar
Body Parsing
# Read entire body as raw bytes
my $bytes = await $req->body;
# Parse JSON body
my $data = await $req->json;
# Parse URL-encoded form
my $form = await $req->form_params;
my $name = $form->get('name');
# Or get a single form value directly
my $email = await $req->form_param('email');
Content-type predicates:
is_json()- True if Content-Type is application/jsonis_form()- True if form data (urlencoded or multipart)
Streaming Large Request Bodies
The body methods above buffer the whole body in memory. For large uploads, stream it instead with body_stream, which returns a PAGI::Request::BodyStream you consume incrementally:
# Pull chunks yourself (undef at end-of-body or client disconnect)
my $stream = $req->body_stream(max_bytes => 100 * 1024 * 1024);
while (defined(my $chunk = await $stream->next_chunk)) {
# ... process $chunk ...
}
# Or drain straight to a file (never holds the whole body in memory)
await $req->body_stream->stream_to_file('/uploads/data.bin');
# Or to a custom async sink — the read pauses until your sink resolves,
# giving you natural backpressure
await $req->body_stream->stream_to(async sub ($chunk) {
await $store->append($chunk);
});
Options: max_bytes (a size cap; defaults to the Content-Length header), decode (e.g. 'UTF-8', which handles multi-byte sequences split across chunk boundaries), and strict (throw on invalid UTF-8).
Streaming is mutually exclusive with the buffered methods. Once you call body_stream, body/text/json/form_params are unavailable on that request (and vice versa) — a body can only be consumed once.
Multipart file uploads stream automatically: $req->upload / $req->uploads spool large parts to a temp file rather than holding them in memory, so you rarely need body_stream for ordinary form uploads. See PAGI::Request::BodyStream for the full API and backpressure examples.
When you would rather own where each upload goes, multipart_stream returns a PAGI::Request::MultipartStream that yields one part at a time:
my $stream = $req->multipart_stream;
while (defined(my $part = await $stream->next)) {
if ($part->is_file) {
await $part->stream_to(async sub ($chunk) { await $sink->write($chunk) });
} else {
my $value = await $part->value;
}
}
This bypasses the auto-spool, so each upload part goes straight to your sink (an S3 client, a transform, an async writer) instead of a temp file, and the sink can be fully asynchronous -- the read pauses until your sink's Future resolves. Slow-client and idle-read timeouts, and the aggregate max_body_size cap, are enforced by the PAGI server, not by this layer (the stream cannot impose a wall-clock timeout itself).
PART 4: BUILDING RESPONSES
4.1 PAGI::Response - Fluent Response Builder
PAGI::Response provides a fluent interface for building HTTP responses. Instead of manually constructing http.response.start and http.response.body events, you use chainable methods.
Creating a Response
use v5.40;
use Future::AsyncAwait;
use PAGI::Response;
async sub app {
my ($scope, $receive, $send) = @_;
die "Expected http scope" unless $scope->{type} eq 'http';
# Create a response value (no connection yet)
my $res = PAGI::Response->new($scope);
# Build the response, then send it via respond($send)
await $res->text('Hello, World!')->respond($send);
}
\&app;
Simple Responses
PAGI::Response provides methods for common response types. Each body method sets the body and returns $self, so you chain respond($send) to transmit:
my $res = PAGI::Response->new($scope);
# Plain text
await $res->text('Hello, World!')->respond($send);
# HTML
await $res->html('<h1>Hello, World!</h1>')->respond($send);
# JSON
await $res->json({ message => 'Hello, World!' })->respond($send);
# Empty response (204 No Content)
await $res->empty()->respond($send);
Factory class methods build a detached response value you can use directly or mount on a router:
# Build and send inline
await PAGI::Response->json({ ok => \1 })->respond($send);
# Mount a static value — dispatch calls respond automatically
$router->mount('/health' => PAGI::Response->json({ ok => \1 }));
Key points:
text()sets Content-Type to text/plain; charset=utf-8html()sets Content-Type to text/html; charset=utf-8json()sets Content-Type to application/json (no charset; JSON is always UTF-8)All methods automatically encode strings to UTF-8 bytes
Status and Headers
Use chainable methods to set status and headers before sending. In a raw app the chain ends with respond($send):
await $res->status(201)
->header('X-Request-ID', '12345')
->header('X-Custom', 'value')
->content_type('application/json')
->json({ created => 1 })
->respond($send);
Alternatively, pass status and headers as options directly to the body factory:
await PAGI::Response->json({ created => 1 },
status => 201,
headers => ['X-Request-ID' => '12345'], # flat [ name => value, ... ]
)->respond($send);
Chainable methods:
status($code)- Set HTTP status (default: 200)header($name, $value)- Add a response headercontent_type($type)- Set Content-Type headercookie($name, $value, %opts)- Set a cookie
Redirects
redirect takes the destination URL and an optional positional status (default 302). In a raw app, chain respond($send) to transmit:
# Temporary redirect (302 Found - default)
await $res->redirect('/new-page')->respond($send);
# Permanent redirect (301 Moved Permanently)
await $res->redirect('/modern', 301)->respond($send);
# See Other (303 - used after POST)
await $res->redirect('/success', 303)->respond($send);
The factory form works the same way:
await PAGI::Response->redirect('/login', 302)->respond($send);
Note: redirect() sets the body and returns $self; the response is not transmitted until respond($send) is called. Use return after the await if you have more code below.
PART 5: REAL-TIME & STREAMING
5.1 PAGI::WebSocket - WebSocket Helper
PAGI::WebSocket simplifies WebSocket connections:
my $ws = PAGI::WebSocket->new($scope, $receive, $send);
await $ws->accept;
# Send and receive
await $ws->send_text("Hello!");
my $msg = await $ws->receive;
# JSON messages
await $ws->send_json({ type => 'greeting' });
my $data = await $ws->receive_json;
# Message loop
await $ws->each_text(async sub {
my ($text) = @_;
await $ws->send_text("Echo: $text");
});
await $ws->close(1000, 'Goodbye');
5.2 PAGI::SSE - Server-Sent Events Helper
PAGI::SSE simplifies Server-Sent Events:
my $sse = PAGI::SSE->new($scope, $receive, $send);
await $sse->start;
# Send events (data is a named argument)
await $sse->send_event(data => "Hello!");
await $sse->send_event(data => "User logged in", event => 'login', id => '42');
# Send JSON with an event name (hashref/arrayref data is auto-encoded)
await $sse->send_event(data => { count => 5 }, event => 'update');
# Or, for a plain JSON message with no event name:
await $sse->send_json({ count => 5 });
# Keepalive to prevent proxy timeouts
await $sse->keepalive(15);
# Wait for disconnect
await $sse->run;
5.3 The Event Dispatcher
When the receive stream mixes protocol events with application events -- for example, middleware that injects pub/sub messages -- PAGI::Context offers a small dispatch loop: register a handler per event type and run once, instead of a hand-rolled while (defined(my $e = await $ctx->receive->())) loop.
my $ctx = PAGI::Context->new($scope, $receive, $send);
await $ctx->accept;
$ctx->on('websocket.receive', async sub {
my ($ctx, $event) = @_;
await $ctx->send_text("echo: $event->{text}");
});
$ctx->on('app.notify', async sub { # an event injected by middleware
my ($ctx, $event) = @_;
await $ctx->send_json({ notice => $event->{message} });
});
$ctx->on_error(sub { my ($ctx, $err, $src) = @_; warn "[$src] $err" });
my $reason = await $ctx->run; # 'disconnect', 'stop', or 'error'
See "Handling a Mixed Event Stream" in PAGI::Tools::Cookbook for a fuller example.
PART 6: MIDDLEWARE
Middleware provides reusable functionality that wraps your application handlers. PAGI includes 30+ middleware components for logging, security, compression, sessions, and more.
6.1 How Middleware Works
At the protocol level there is nothing special about middleware: it is simply a PAGI application that wraps another PAGI application. It receives the same ($scope, $receive, $send), does its work before and/or after, and delegates to the app it wraps. That lets a middleware:
Modify the request scope before the inner app sees it
Short-circuit the request and respond early, without calling the inner app
Intercept and modify the response on its way out
Add side effects such as logging or timing
The raw-PAGI version
Say we want to stamp every response with an X-Powered-By header. With nothing but the protocol, a middleware is a function that wraps an app and intercepts the http.response.start event by wrapping $send:
use Future::AsyncAwait;
sub add_powered_by {
my ($app) = @_;
return async sub {
my ($scope, $receive, $send) = @_;
# Wrap $send so we can edit the response-start event on its way out
my $wrapped_send = async sub {
my ($event) = @_;
if ($event->{type} eq 'http.response.start') {
push @{ $event->{headers} }, ['x-powered-by', 'PAGI'];
}
await $send->($event);
};
await $app->($scope, $receive, $wrapped_send);
};
}
It works, but you write the $send-wrapping boilerplate by hand, and you compose middleware by nesting function calls — which reads inside-out and hides the running order:
my $app = add_powered_by( time_requests( access_log( $inner_app ) ) );
The PAGI::Middleware version
Here is the same middleware as a PAGI::Middleware subclass. The base class supplies the wrapping helpers; intercept_send hands your callback each outgoing $event together with the original $send to forward it to:
package MyApp::Middleware::PoweredBy;
use parent 'PAGI::Middleware';
use Future::AsyncAwait;
sub wrap {
my ($self, $app) = @_;
return async sub {
my ($scope, $receive, $send) = @_;
my $wrapped_send = $self->intercept_send($send, async sub {
my ($event, $orig_send) = @_;
push @{ $event->{headers} }, ['x-powered-by', 'PAGI']
if $event->{type} eq 'http.response.start';
await $orig_send->($event);
});
await $app->($scope, $receive, $wrapped_send);
};
}
1;
Now it composes with the builder as a flat stack that reads top-to-bottom, in the order it runs:
use PAGI::Middleware::Builder;
my $app = builder {
enable '^MyApp::Middleware::PoweredBy';
enable 'AccessLog';
enable 'Runtime';
$router;
};
Why the toolkit version is worth it
The behavior is identical — what you gain is everything around it:
Composition. The
builderstack reads in run order, top to bottom, instead of inside-out function nesting. Adding, removing, or reordering a middleware is a one-line change.Configuration. A middleware is a configurable object —
enable 'GZip', min_size => 1024— with construction-time validation, rather than a closure capturing lexicals.Reuse. It is an ordinary class: ship it on CPAN, share it between apps, or reach for one of the 30+ already in this distribution (see "6.3 Essential Middleware Reference") rather than writing your own.
Conditional and per-route use.
enable_if { ... } 'Name'applies a middleware only when a condition holds, and any route can carry its own middleware list (see "6.2 Using Middleware with Builder").Helpers. The base provides
intercept_send,modify_scope,buffer_request_body, and friends, so each middleware isn't re-implementing the protocol plumbing.
6.2 Using Middleware with Builder
The PAGI::Middleware::Builder DSL lets you compose middleware into your application.
Global Middleware
Apply middleware to all routes using the builder function:
use PAGI::Middleware::Builder;
use PAGI::App::Router;
my $router = PAGI::App::Router->new;
$router->get('/' => async sub {
my ($scope, $receive, $send) = @_;
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({
type => 'http.response.body',
body => 'Hello World',
});
});
# Wrap with middleware using builder; the block's final value can be
# an app coderef, a component, or (as here) the router itself
my $app = builder {
enable 'AccessLog', format => 'combined';
enable 'CORS', origins => '*';
enable 'GZip', min_size => 1024;
$router;
};
Middleware executes in order: AccessLog -> CORS -> GZip -> app.
Multiple Middleware Example
my $app = builder {
enable 'RequestId'; # Add X-Request-Id header
enable 'AccessLog', format => 'tiny'; # Log requests
enable 'Runtime'; # Add X-Runtime header
enable 'CORS', origins => '*'; # Enable CORS
enable 'SecurityHeaders'; # Add security headers
enable 'GZip', min_size => 1024; # Compress responses
enable 'ErrorHandler', development => 1; # Pretty errors
$router;
};
Middleware Instances
enable also accepts an already-configured middleware instance — useful when you build the instance elsewhere or want construction-time errors. The parentheses are required for the instance form:
my $gzip = PAGI::Middleware::GZip->new(min_size => 1024);
my $app = builder {
enable 'AccessLog', format => 'tiny';
enable($gzip);
$router;
};
6.3 Essential Middleware Reference
Logging & Debugging
-
Log HTTP requests in Apache-style formats:
enable 'AccessLog', format => 'combined'; # Apache combined log enable 'AccessLog', format => 'common'; # Apache common log enable 'AccessLog', format => 'tiny'; # Minimal format -
Add unique request ID to each request:
enable 'RequestId'; # Adds X-Request-Id headerAccess via
$scope->{request_id}. -
Add response time header:
enable 'Runtime'; # Adds X-Runtime header in seconds
Security
-
Enable Cross-Origin Resource Sharing:
enable 'CORS', origins => '*', # or ['https://example.com'] methods => ['GET', 'POST'], headers => ['Content-Type'], credentials => 1, max_age => 86400; PAGI::Middleware::SecurityHeaders
Add security-related HTTP headers:
enable 'SecurityHeaders'; # Adds CSP, X-Frame-Options, etc.Includes:
X-Frame-Options: SAMEORIGIN X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Referrer-Policy: strict-origin-when-cross-origin-
Protect against Cross-Site Request Forgery:
enable 'CSRF', cookie_name => '_csrf_token';Validates CSRF tokens on POST/PUT/PATCH/DELETE requests.
Sessions & Cookies
-
Parse request cookies:
enable 'Cookie';Access via
$scope->{'pagi.cookies'}. -
Server-side sessions with signed cookies:
enable 'Session', secret => 'your-secret-key', cookie_name => 'session', expire => 86400;Access via
$scope->{'pagi.session'}.
Performance
-
Compress responses with gzip:
enable 'GZip', min_size => 1024, # Only compress if > 1KB mime_types => ['text/', 'application/json']; -
Automatic ETag generation and validation:
enable 'ETag';Returns 304 Not Modified for matching If-None-Match headers.
-
Rate limiting with configurable strategies:
enable 'RateLimit', requests_per_second => 100, # token-bucket refill rate burst => 100, # bucket capacity (max burst) key_generator => sub { # key generator (defaults to client IP) my ($scope) = @_; return $scope->{client}[0]; # by IP };
Error Handling
PAGI::Middleware::ErrorHandler
Catch and format errors:
enable 'ErrorHandler', development => 1; # Pretty HTML errors with stack traces enable 'ErrorHandler'; # Production (default): generic errors, no stack leakErrorHandlerrenderstext/htmlby default; setcontent_type => 'application/json'(or'text/plain') to change the format.
6.4 Writing Custom Middleware
Create reusable middleware by extending PAGI::Middleware:
package MyApp::Middleware::Auth;
use parent 'PAGI::Middleware';
use Future::AsyncAwait;
sub new {
my ($class, %args) = @_;
return bless { secret => $args{secret} }, $class;
}
sub wrap {
my ($self, $app) = @_;
return async sub {
my ($scope, $receive, $send) = @_;
# Check auth header
my $auth = $self->get_header($scope, 'Authorization');
if ($auth && $auth =~ /^Bearer (.+)/) {
my $token = $1;
my $user = verify_token($token, $self->{secret});
PAGI::Stash->new($scope)->set(user => $user) if $user;
}
# Call next middleware/app
await $app->($scope, $receive, $send);
};
}
sub get_header {
my ($self, $scope, $name) = @_;
$name = lc $name;
for my $header (@{$scope->{headers} || []}) {
return $header->[1] if lc($header->[0]) eq $name;
}
return undef;
}
sub verify_token {
my ($token, $secret) = @_;
# Token verification logic here
return { id => 1, username => 'alice' };
}
1;
Use it with the builder:
my $app = builder {
enable '^MyApp::Middleware::Auth', secret => 'secret-key';
$router;
};
The ^ prefix tells the builder to use the class name as-is instead of prepending PAGI::Middleware::. Or skip name resolution entirely by passing a configured instance (parentheses required):
my $app = builder {
enable(MyApp::Middleware::Auth->new(secret => 'secret-key'));
$router;
};
Or per-route:
my $auth = MyApp::Middleware::Auth->new(secret => 'secret-key');
$router->get('/admin' => [$auth] => $handler);
Middleware Patterns
Modify scope
Add data for downstream handlers:
sub wrap { my ($self, $app) = @_; return async sub { my ($scope, $receive, $send) = @_; PAGI::Stash->new($scope)->set(custom_data => 'value'); await $app->($scope, $receive, $send); }; }Intercept responses
Modify outgoing events:
sub wrap { my ($self, $app) = @_; return async sub { my ($scope, $receive, $send) = @_; my $wrapped_send = $self->intercept_send($send, async sub { my ($event, $original_send) = @_; if ($event->{type} eq 'http.response.start') { push @{$event->{headers}}, ['x-custom', 'value']; } await $original_send->($event); }); await $app->($scope, $receive, $wrapped_send); }; }Short-circuit requests
Return early without calling inner app:
sub wrap { my ($self, $app) = @_; return async sub { my ($scope, $receive, $send) = @_; # Check condition unless ($self->is_authorized($scope)) { await $send->({ type => 'http.response.start', status => 403, headers => [['content-type', 'text/plain']], }); await $send->({ type => 'http.response.body', body => 'Forbidden', }); return; # Don't call $app } await $app->($scope, $receive, $send); }; }
PART 7: COMPOSING APPLICATIONS
PAGI ships with several ready-to-use applications for common tasks. These can be mounted with routers or used standalone.
7.1 PAGI::App::File - Static Files
PAGI::App::File serves static files with security, caching, and streaming:
use PAGI::App::File;
my $static = PAGI::App::File->new(root => './public');
$router->mount('/static' => $static);
Features:
Efficient streaming (large files don't consume memory)
ETag caching with 304 Not Modified support
HTTP Range requests for resume support
Automatic MIME type detection
Security: path traversal protection
7.2 PAGI::App::Healthcheck - Health Endpoints
PAGI::App::Healthcheck creates health check endpoints:
use PAGI::App::Healthcheck;
my $health = PAGI::App::Healthcheck->new(
version => '1.0.0',
checks => {
database => sub { $db && $db->ping },
cache => sub { $redis && $redis->ping },
},
);
$router->mount('/health' => $health);
7.3 PAGI::App::URLMap - Mount Applications
PAGI::App::URLMap routes requests to different apps based on URL prefix:
use PAGI::App::URLMap;
my $urlmap = PAGI::App::URLMap->new;
$urlmap->mount('/api' => $api_app);
$urlmap->mount('/admin' => $admin_app);
$urlmap->mount('/static' => $static);
$urlmap->to_app;
7.4 PAGI::App::Cascade - Try Apps in Sequence
PAGI::App::Cascade tries apps in order until one returns a non-404:
use PAGI::App::Cascade;
my $app = PAGI::App::Cascade->new(
apps => [$static, $api, $fallback],
catch => [404, 405],
);
7.5 PAGI::App::Proxy - Reverse Proxy
PAGI::App::Proxy forwards requests to backend servers:
use PAGI::App::Proxy;
my $proxy = PAGI::App::Proxy->new(
backend => 'http://localhost:8080',
timeout => 30,
);
$router->mount('/api' => $proxy);
7.6 PAGI::App::WrapPSGI - Use Existing PSGI Apps
PAGI::App::WrapPSGI lets you use existing PSGI applications:
use PAGI::App::WrapPSGI;
my $wrapped = PAGI::App::WrapPSGI->new(psgi_app => $legacy_app);
$router->mount('/legacy' => $wrapped);
Useful for incremental migration from PSGI to PAGI.
7.7 Application Composition
Every place this toolkit accepts an application coerces its argument through "to_app" in PAGI::Utils, so three forms are interchangeable in app position:
An async coderef — the protocol-level form, used unchanged
A component object — anything with a
to_appmethod, compiled once at composition time:PAGI::App::File->new(root => 'public')A class name string — auto-required if needed, then compiled via the class-method
to_app:'MyApp::API'
App positions are: mount targets and route handlers in PAGI::App::Router, mount and the final block value in PAGI::Middleware::Builder's builder {}, PAGI::App::URLMap mounts and its default, PAGI::App::Cascade entries, and the app argument to PAGI::Test::Client. Put together:
my $app = builder {
enable 'AccessLog';
mount '/static' => PAGI::App::File->new(root => 'public');
mount '/api' => 'MyApp::API';
PAGI::Response->text('no such page')->status(404);
};
Compilation happens exactly once, when the composition point receives the component — never per request.
Middleware position is the same idea with a different duck type: enable accepts a middleware name (resolved under PAGI::Middleware::, with ^ to escape) or a configured instance with a wrap method, and per-route middleware arrays in PAGI::App::Router accept instances or ($scope, $receive, $send, $next) coderefs.
Passing middleware where an app belongs (or vice versa) croaks at composition time with a message pointing at the right position.
The one place that still wants an explicit ->to_app is the value your app.pl hands to the server: the server contract is a plain coderef, so end your file with $router->to_app (or a builder {} block, which already returns one).
SEE ALSO
PAGI::Tutorial - the PAGI protocol tutorial (the foundation)
PAGI - the PAGI specification distribution
PAGI::Tools::Cookbook - recipes for common tasks
PAGI::Middleware::Builder, PAGI::App::Router, PAGI::Response, PAGI::Request - individual module documentation
AUTHOR
PAGI Contributors
COPYRIGHT AND LICENSE
This software is copyright (c) 2025 by the PAGI contributors.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.