Suppose we could redesign Catalyst without preserving every compatibility
decision it has accumulated. Which parts would we keep because they still
work? Which would we simplify? Which pain points have experienced Catalyst
developers learned to work around, but should not be inherited by a new
framework?
CatalystNext is the working name for that redesign. This is not a v1
proposal. I want to make the consequential choices visible while Catalyst
users can still challenge them: subroutine attributes or a DSL, chained
dispatch or another routing model, action and controller namespaces,
request-scoped state, services and MVC, and the boundary between a small core
and a complete framework.
PAGI, the Perl Asynchronous Gateway
Interface, would give CatalystNext an async-native execution foundation.
Supporting APIs, WebSockets, streams, MCP, and other current workloads
matters, but modernization is not the main question of this post. The main
question is whether the proposed programming model addresses Catalyst's real
strengths and pain points.
1. What should writing CatalystNext feel like?
Subroutine attributes, a route DSL, or conventions?
Catalyst is, for better or worse, a subroutine-attribute framework. An action
looks like a Perl subroutine with declarations attached to it. That keeps the
route metadata beside the code that handles the route, and it is an important
part of Catalyst's identity.
There are other credible choices.
A dedicated route DSL can show the whole application more clearly and avoid
some of Perl's attribute machinery. Rails demonstrates how much can follow
from a compact resource declaration:
resources :photos expands
into the conventional collection and member routes, named helpers, and
controller actions. At the other end, an explicit route table can make every
method, path, and handler visible without any convention.
FastAPI takes another
path: decorators remain close to the handler, while ordinary Python type
declarations also drive parsing, validation, serialization, and generated
OpenAPI documentation. The interesting part is not the Python syntax; it is
how much useful application metadata becomes available from one declaration.
My instinct is that CatalystNext should remain attribute-driven at its main
user-facing surface. I am not in love with inventing a large new DSL, and I
do not want a supposedly convenient route convention to conceal most of the
application.
Questions I would value feedback on:
- Are subroutine attributes still a pleasant and idiomatic way to describe actions in modern Perl?
- Is having route information beside the handler more valuable than seeing the complete route tree in one file?
- Which declarations should remain explicit even if a Rails-like resource shortcut is available?
- Would it be useful for attributes, a DSL, and conventions to be alternative front ends over the same underlying application model, or would that merely recreate Catalyst's “too many ways” problem?
The declaration surface does not settle what those declarations build. That
leads to the dispatch model itself.
What kind of dispatcher?
I am strongly inclined to keep chained dispatch. Chains remain a good way to
express the structure that real applications repeat:
identify tenant
→ authorize user
→ load project
→ load issue
→ perform endpoint
The problem is that Catalyst's historical chained syntax accumulated several
ways to say similar things. Controller namespaces, action namespaces, path
parts, captures, and private action names could all interact. People often
found chaining powerful only after they had survived learning how its pieces
were bolted together.
We are not bound by that compatibility surface in a new framework. The goal
would be to retain chains while separating three things that old Catalyst
often allowed to blur together:
- Code origin — the package and subroutine containing the code.
- Logical action name — the stable name used for chaining, diagnostics, and reverse lookup.
- URL path — the path used for HTTP dispatch.
Here is one deliberately incomplete syntax experiment:
package MyApp::Controller::AddressBook;
# URL: /contacts
sub contacts :Via('/') :At('contacts') {
# Work shared by the collection.
}
# URL: /contacts/{contact_id}
sub contact :Via('../contacts') :At('{contact_id:\d+}') {
# Work shared by one contact.
}
# Matches: GET /contacts
sub list :Via('../contacts') :Get('') {
}
# Matches: GET /contacts/{contact_id}
sub show :Via('../contact') :Get('') {
}
In this experiment, Via refers to a logical action and At contributes a
URL fragment. Get($path) is simply At($path) plus the HTTP GET method.
The same rule would apply to Post, Patch, and the other HTTP verbs.
The logical name of show is /contacts/contact/show. Its URL is
/contacts/{contact_id}. Its Perl package is
MyApp::Controller::AddressBook. Those names are related only because the
author chose them to be readable.
This is a strawman, not a finished grammar. In particular, relative action
references such as Via('../contact') need to remain understandable when an
application has several actions named contact. Ambiguity should be a boot
error, not a load-order rule.
Rails-style resource dispatch has an obvious advantage here: most developers
already know what index, show, create, update, and destroy mean.
Explicit route tables are also easier to explain than an action graph.
Chaining has to earn its additional conceptual machinery.
A second simplification may come from the execution model rather than the
route grammar. If the terminal action returns a response, each intermediate
action can be middleware around the rest of the chain: call the next action
and return its response, transform or replace that response, or return a
response immediately to short-circuit dispatch.
This would be CatalystNext middleware over the context and response values,
not raw PAGI middleware over event channels. It would give authorization,
resource loading, and response decoration one composable shape. The separate
question of whether action return values should acquire this meaning appears
below.
Questions:
- Do chains still model something important that middleware, dependency injection, or nested routers do not?
- Can a much smaller
ViaplusAtvocabulary make chained dispatch approachable? - Does treating intermediate chain actions as response-value middleware make the chain easier to understand?
- Should resourceful CRUD routes be a dispatcher of their own, a convenience that produces a chain, or not part of the core?
- Should one application be allowed to combine dispatch strategies, or does a framework need one blessed model?
Even after choosing chains, the framework must decide where the names in those
chains come from.
Should controller namespaces mean anything?
My current leaning is that a controller exists for code organization only.
Its package should tell the loader and the programmer where the code lives;
it should not silently contribute a URL prefix or an action namespace.
This will feel strange to people coming from frameworks where
Admin::UsersController naturally implies an /admin/users area. It also
removes an entire category of accidental coupling: reorganizing a large file
into several controllers would not change routes, reverse-route names, or
authorization behavior.
The action's logical namespace would instead come from its place in the
chain. A route dump would always show code origin, logical action, and URL
together, so the separation is visible rather than magical.
Questions:
- Is a purely organizational controller liberating, or does it discard a useful convention?
- When code origin and route structure differ, is a good route-inspection command sufficient to keep the application comprehensible?
- Should an application be able to opt into package-derived defaults, even if the framework's underlying model keeps the concepts separate?
The authoring model tells us how actions are named and reached. The next layer
is what enters, leaves, and flows between those actions.
2. What flows through the application?
Representations, content negotiation, and schemas
For a long time, content negotiation promised that one resource could produce
HTML, JSON, XML, or another representation based on the request. In practice,
many applications created an HTML site and a separately versioned JSON API.
JSON won a very large part of the API world, while server-rendered HTML has
continued to evolve rather than vanish.
Should one logical action still negotiate among representations? Or is
GET /contacts for HTML conceptually different from
GET /api/v1/contacts for JSON, even when both use the same underlying
service?
Schemas raise a related question. OpenAPI can document and generate clients
for HTTP APIs. MCP tool definitions use JSON Schema for inputs and can declare
schemas for structured outputs. A framework with a reliable model of action
inputs and results could potentially generate much of this rather than asking
the author to describe the same contract several times.
But “generate the schema” can mean several incompatible things:
- infer a best-effort schema from Perl code;
- require typed request and response declarations;
- start with a schema and generate Perl-facing types;
- treat schemas as optional metadata which extensions may consume.
Questions:
- Is content negotiation still a useful default, or should representations be explicit actions?
- How much type and schema information will Perl developers realistically maintain?
- Should validation, serialization, OpenAPI, and MCP schemas share one type model?
- At what point does schema-driven development stop feeling like Perl?
Schemas describe the public boundary of an action. Chained applications also
need a disciplined way to move values across their internal boundaries.
Request state, services, models, and views
Catalyst's stash was wonderfully flexible. That flexibility also made it easy
for action chains and views to communicate through undocumented hash keys.
I am considering a more structured replacement based on scoped attributes. A
controller could declare an attribute with request scope. Once a chain action
establishes its value, that value would be available to downstream
controllers and to a called view.
Conceptually:
has current_contact => (
scope => 'request',
isa => Contact,
);
That could make the data flowing through a chain explicit and introspectable.
It immediately creates hard questions:
- Is the value write-once, replaceable, or lazily built?
- Is it visible only downstream or everywhere in the request?
- Who performs async construction and cleanup?
- Does a view receive it automatically?
- Should a model or domain service ever see request-scoped state implicitly, or is that a layering violation?
There is a second naming problem here. “Model” has meant everything from a
database handle to a domain object to an external API client. “View” can mean
a template, serializer, response object, or negotiated representation.
Perhaps the framework should fundamentally understand services with
lifetimes—application, request, connection—and allow models and views to be
useful application-level categories layered on top. Or perhaps abandoning
strong MVC vocabulary would discard one of Catalyst's most helpful organizing
ideas.
I would particularly like examples from larger applications: where did
request-local state help, where did implicit availability cause trouble, and
which resources genuinely needed application, request, or connection scope?
These authoring and data-flow choices should address problems people have
actually encountered in Catalyst applications. They also cannot assume that
the applications we build today look exactly like the applications Catalyst
was originally designed to serve.
3. What else must the redesign support?
When Catalyst first appeared, the central use case was a database-backed,
server-rendered website: templates, forms, sessions, and HTML responses.
That remains important, but “web framework” now covers several workloads with
related but non-identical needs:
- server-rendered HTML, forms, redirects, and sessions;
- JSON APIs and webhooks;
- bidirectional WebSockets and server-sent event streams;
- large streaming request and response bodies;
- MCP tools, resources, and prompts;
- possibly work that outlives one HTTP request.
HTML did not disappear, but a new framework should not assume that every
request ends by selecting a template.
MCP makes the changed landscape especially visible. MCP's data layer is based
on JSON-RPC and exposes primitives such as tools, resources, and prompts.
Remote MCP commonly uses
Streamable HTTP,
but an MCP tool is not naturally an HTTP path. Its identity, input schema,
capabilities, progress notifications, and result types matter more than
inventing a REST-shaped URL for it.
Questions:
- Are HTTP, WebSocket, SSE, and MCP several protocol front doors into one application, or separate framework layers sharing services?
- Should MCP be a first-class CatalystNext concept or an add-on that consumes the same metadata as HTTP APIs?
- What request or connection lifecycle is shared across these protocols?
- Where should long-running work stop being a controller action and become a job or service?
These workloads do not need identical controller APIs, but they do need an
execution foundation capable of supporting all of them. That is where PAGI
enters the design.
4. PAGI as the foundation, not necessarily the user interface
PAGI applications receive a connection scope plus asynchronous receive and
send functions. This supports multiple incoming and outgoing events and
makes backpressure visible.
CatalystNext should take advantage of that foundation without forcing every
ordinary database lookup or HTML response to look like low-level protocol
code. “Async-native” needs to become a set of application-level guarantees:
- synchronous handlers remain straightforward;
- asynchronous handlers compose without blocking the server;
- cancellation reaches database queries, streams, and child work where possible;
- request and connection resources are reliably cleaned up;
- streaming does not require bypassing the framework;
- dropping to raw PAGI remains possible at a defined boundary.
The controversial part is how much of that contract belongs in CatalystNext
and how much belongs in PAGI-Tools, an event loop, or service implementations.
Should an action's return value mean something?
In Catalyst, an action's return value has no framework meaning. An action
changes the response attached to the context, and the dispatcher ignores
whatever the subroutine returns. The introductory documentation demonstrates
this by setting
$c->res->body
without returning a response.
That has confused people. Every Perl subroutine returns something, even
without an explicit return, yet return $something from a Catalyst action
has no effect on dispatch. The action's real result accumulates elsewhere on
the context.
There is a reasonable case for leaving this alone. A Catalyst action is a
stage in a request lifecycle, not necessarily a function which produces a
result. Several actions and a view can cooperate through the context, and an
accidental final expression cannot unexpectedly become the response.
But CatalystNext could make the terminal action's return value part of its
contract. It would have to return an object which isa PAGI::Response.
CatalystNext could supply a CatalystNext::Response subclass with framework
conveniences while accepting other PAGI response subclasses.
PAGI::Response is a detached
response value, so an action could be tested by inspecting its returned
status, headers, and body without a live connection. Response ownership,
redirects, errors, views, and chain short-circuits would all use the same
explicit value. The dispatcher could reject undef or an unrelated value
instead of guessing whether some other code completed the response.
The cost is a stricter and less forgiving endpoint contract. It may add
ceremony, fit awkwardly with views which build a response in several steps,
and turn Perl's harmless implicit return into a validation error.
Should CatalystNext give an action's return value framework meaning, or is
Catalyst's current behavior a useful consequence of its request lifecycle?
Even with a response-value contract for ordinary endpoints, some applications
will need direct ownership of the PAGI event channels. That is a separate
boundary.
How PAGI-shaped should an action be?
There is still a lower-level question underneath the response-value contract:
should ordinary actions see only the CatalystNext chain described above, or
should a terminal action be able to become a literal PAGI application and an
intermediate action become literal PAGI middleware?
PAGI::Nano provides a useful
precedent. A Nano application is an ordinary PAGI application, but most HTTP
handlers receive a friendly context and return a response value which Nano
sends as PAGI events. Middleware uses the PAGI-shaped
($scope, $receive, $send, $next) contract. A raw route can take ownership
of its response and reach the underlying channels when it needs them.
That gives Nano a “no silo, no cliff” property: application code does not pay
the raw protocol cost for an ordinary JSON response, but it never becomes
trapped above PAGI.
CatalystNext could take a similar approach with an explicit :Raw indicator
(the name is only a strawman). The routing attributes are omitted from this
example so we can focus on execution shape:
# An ordinary endpoint returns a CatalystNext::Response.
async sub show ($self, $c) {
return $self->view(show => contact => $self->current_contact);
}
# A raw intermediate action might receive the full middleware contract.
async sub observe :Raw (
$self, $scope, $receive, $send, $next
) {
my $observed_send = async sub ($event) {
$self->metrics->increment($event->{type});
await $send->($event);
};
await $next->($scope, $receive, $observed_send);
}
# A raw endpoint owns its response and can emit any PAGI event.
async sub feed :Raw ($self, $c) {
my $emit = $c->raw_send;
await $emit->({ type => 'contacts.feed.start' });
await $emit->({
type => 'contacts.updated',
contact_id => 42,
});
await $emit->({ type => 'contacts.feed.end' });
}
Here :Raw changes execution ownership, not routing. On a terminal action it
means that the action must complete the response or protocol exchange. On an
intermediate action it could mean that the action receives the full
around-middleware contract. That second shape can run before and after the rest
of the chain, replace or wrap receive and send, short-circuit dispatch, and
observe errors. It operates at a lower level than ordinary response-value
middleware, which sees the CatalystNext context, next, and the response
returned by the rest of the chain without taking ownership of the event
channels.
Keeping the PAGI event channels reachable also creates an extension seam
which response values alone cannot provide. A handler might emit a semantic
custom event such as contacts.updated.
A surrounding middleware could observe that event for metrics or tracing,
reject it, enrich it, or translate it into SSE, NDJSON, or some future wire
protocol. The handler can speak in application events while the wrapper owns
the representation. PAGI::Nano already
demonstrates this pattern
with custom send events rendered into more than one wire format.
Whether multiple wire formats should share one logical action is the
content-negotiation question discussed above.
The risk is making every CatalystNext action reason in transport-level terms.
Raw receive and send are flexible, but they also expose event ordering,
response ownership, awaiting every send, and protocol-specific failure modes.
The framework is supposed to design those footguns out of ordinary application
code.
So the question may not be whether CatalystNext is built from PAGI apps and
middleware—it almost certainly will be—but where that fact becomes visible:
- Should every endpoint and intermediate action directly implement a PAGI contract?
- Should ordinary actions receive a CatalystNext context and compile into PAGI
apps or middleware, with
:Rawas the explicit ownership escape hatch? - Can
:Rawsensibly mean terminal PAGI application on an endpoint and PAGI middleware on an intermediate action, or are those different concepts which deserve different indicators? - Should every ordinary intermediate chain action receive
nextand behave as response-value middleware, or should the framework also have simpler one-way setup stages? - Should custom event types be registered in the metamodel so extensions and tooling can discover them, or remain an intentionally open convention?
PAGI determines what CatalystNext can execute, but it does not determine the
right size of the framework or how much of Catalyst's existing shape a
redesign should preserve. Those are product decisions rather than protocol
decisions.
5. Where is the framework boundary?
How much compatibility should the name “Catalyst” imply?
I do not want to preserve awkward syntax solely because Catalyst had to remain
compatible with itself. A clean design needs permission to remove duplicate
spellings and historical namespace behavior.
At the same time, a framework called CatalystNext creates a reasonable
expectation that Catalyst developers can recognize its concepts and migrate
incrementally. PAGI can host existing PSGI applications through an adapter,
which creates possibilities for gradual migration at the application
boundary. It does not automatically provide source compatibility inside a
controller.
What matters most to potential users: familiar concepts, compatible
controller code, coexistence with an old application, migration tooling, or
simply a clear explanation of the break?
Even with permission to break old syntax, we still need to decide how much
belongs in the new core.
Small core or complete framework?
There is a perennial framework argument underneath all of this: should the
core be small and composable, or should a new application receive a coherent
answer for routing, configuration, services, validation, sessions,
authentication, rendering, errors, testing, and deployment?
A tiny core is easier to understand and less likely to freeze yesterday's
choices. It can also leave every application assembling a subtly incompatible
stack. A batteries-included framework creates shared vocabulary and better
out-of-the-box tooling, but every additional blessed subsystem raises the
cost of maintenance and makes alternative choices feel second-class.
My leaning is that CatalystNext should be small but complete and flexible.
That was one of Catalyst's strengths. It supplied enough shared structure for
an application to feel like one system, without insisting that every useful
idea had to live inside the core distribution.
“Complete” does not mean incorporating every fashionable application model.
It means defining a coherent application model, lifecycle, routing and
dispatch contract, service boundary, error path, and extension surface.
People should then be able to build more opinionated experiences on top:
Livewire- or LiveView-like stateful UI frameworks, API-focused stacks, MCP
tooling, or ideas none of us have named yet.
That still leaves a difficult boundary. Is schema generation a layer? Are
sessions? Are HTML views? Does authentication belong in the action model, in
middleware, or entirely in application code? How much must the core
standardize so independently developed extensions compose rather than merely
coexist?
I would like to hear which facilities must work together on day one for
CatalystNext to deserve the word “framework,” and which should remain
replaceable integrations.
Being both complete and extensible requires a shared structural vocabulary.
That is what keeps pulling me toward a metamodel rather than a collection of
unrelated hooks.
My current architectural leaning: build around a metamodel
The idea I keep returning to is a standalone application metamodel: something
structural and extensible, somewhat MOP-like in spirit but not based on Moose
and not limited to implementing CatalystNext.
It might contain concepts along these lines:
Meta::App
Meta::Controller
Meta::Action
Meta::Dispatcher
Meta::Service
Meta::View
Meta::Attribute
The exact class list is not the important part yet. The important separation
is between describing an application and turning that description into a
running PAGI app.
Subroutine attributes could build the model. A dedicated DSL or a
resource-routing convention could also build the model. Dispatchers could
compile actions into executable routing graphs. Extensions could inspect or
contribute metadata for OpenAPI, MCP, authorization, or developer tooling.
An application could fail at boot when routes collide or a chain consumes a
value nobody provides.
Spring WebFlux
is one useful precedent for separating these concerns: it supports both
annotated controllers and functional endpoint routing over the same reactive
web foundation. That does not mean CatalystNext should expose every possible
frontend. “They all compile to metadata” is an architectural capability, not
an excuse to avoid choosing one good default.
My hope is that the metamodel gives CatalystNext a sane, stable, introspectable
API for extension. Routes, actions, dispatchers, services, views, and scoped
attributes should be discoverable through documented objects rather than by
reverse-engineering controller packages or mutating framework internals.
Extensions should be able to contribute metadata, validate the application
model, and participate in compilation at defined points, while CatalystNext
still provides one opinionated default experience.
The metamodel also should not become a stealth decision about how application
objects are written. Ideally plain Perl, Moo, Moose, Object::Pad, and core
class code could participate through a structural contract. I am less sure
how far that neutrality can go before useful features become lowest-common-
denominator abstractions.
This is still only a direction. A metamodel can make routes inspectable and
extensions cleaner, but it can also become an abstract architecture project
that never produces a pleasant application framework. The user-facing
experience has to come first.
What feedback would help
I am not looking for a vote on whether every idea above is good. I would most
value reports from experience: what hurt, what scaled, and what you would not
want a redesign to lose.
- Which Catalyst behavior proved valuable in a large application, even if its syntax was confusing?
- Which Catalyst pain points have you learned to tolerate or work around?
- Which routing model do you find easiest to understand six months after writing it?
- Should an action's return value have framework meaning? If terminal actions
return a
PAGI::Response, does treating intermediate chain actions as middleware make chains clearer? - Where have framework conventions saved work, and where have they hidden too much?
- How have you successfully handled request-scoped values and async resource lifetimes?
- Would typed request and response declarations earn their cost through validation, OpenAPI, and MCP integration?
- What kinds of Perl web applications are you building—or would you build if the framework support existed?
- Where has direct access to an async protocol or custom event stream been useful, and where did it leak too much machinery into application code?
- Which facilities must be integrated before a framework is meaningfully more useful than a router and middleware collection?
- What would a Livewire- or LiveView-like layer need from the core in order to remain an extension rather than a fork?
- Should CatalystNext impose an object system, favor one without requiring it, or remain completely structurally typed?
- Does a reusable metamodel sound like a useful foundation, or like premature generalization?
Please feel free to answer one narrow part. A detailed objection to one
assumption is more useful at this stage than general encouragement.
I have spent enough time trying to imagine the perfect v1 in isolation. I
would rather find the shape of the problem with the community before turning
these experiments into promises.
Top comments (0)