Controllers Overview
In this guide, you will learn how controllers work and how they fit into the request cycle in your application.
- How to generate a controller, and what the generator gives you.
- How
@router.resourcemaps actions to URLs. - Access parameters passed to your controller.
- How to render templates, return JSON, and redirect.
- Store data in the cookie, the session, and one-shot flash messages.
- How
beforeandaftercallbacks let you run code around your actions. - How concerns let you share callbacks and helpers across controllers.
1. Introduction
After the router has matched a controller to an incoming request, the controller is responsible for processing the request and produce a response - usually an HTML page, sometimes a redirect, sometimes JSON.
Controllers live in myapp/controllers/, one class per file. Each class inherits from AppController:
# myapp/controllers/card_controller.py
from ..router import router
from ..models import Card
from .app_controller import AppController
@router.resource("cards")
class CardController(AppController):
def index(self):
self.cards = Card.select()
That's a complete controller. Visit /cards in the browser and Proper will run index, set self.cards, render pages/card/index.jx, and send the result back.
1.1 Controller Naming Convention
Proper expects three things from a controller's name:
- Singular -
CardController, notCardsController. Even when the URL is/cards. - Suffixed with
Controller- the framework relies on the suffix to derive route names and template paths. - Filename matches in snake_case -
CardControllerlives incard_controller.py.
Tip
If you stick to the generator (next section), you don't have to think about any of this; the right names fall out automatically.
1.2 AppController
Every controller you write inherits from AppController, not directly from Controller. AppController lives in your application and is where you put behavior that should apply to every page:
# myapp/controllers/app_controller.py
from proper import Controller
from proper.concerns import OriginProtection, RateLimiting
from .concerns.form_validation import FormValidation
from .concerns.security_headers import SecurityHeaders
class AppController(
Controller,
OriginProtection,
RateLimiting,
FormValidation,
SecurityHeaders,
):
pass
The mixins it inherits from are concerns - small classes that bundle behavior. We'll come back to them in Concerns; for now, just know that the four shipped here give you CSRF protection, rate limiting, form validation, and sensible security headers without you having to wire them up.
2. Generating a Controller
You almost never write a controller from a blank file. Use the generator - it creates the controller, the form, the views, and (for resources) the model and its migration, all wired up correctly.
2.1 With a Model: proper g resource
If the controller is going to manage a database-backed thing - a card, a post, a user - generate a resource:
proper g resource Card title:str body:text
This produces:
myapp/models/card.py- the model.migrations/NNN_create_cards.py- the migration that creates the table.myapp/forms/card.py- the form, with one field per model field.myapp/controllers/card_controller.py- the controller, with all seven CRUD actions.myapp/views/pages/card/-index.jx,show.jx,new.jx,edit.jx, andform.jx.
Run proper g resource --help to see all the options.
2.2 Without a Model: proper g controller
If the controller doesn't have a model behind it - a DashboardController, a HealthcheckController - generate just a controller:
proper g controller Dashboard
This produces the controller, an empty form (you can delete it if you don't need one), and the view files.
2.3 If You Only Need a Model
If you have a model but no new controller - say, a Tag model that's only ever managed through the admin UI - use the model generator instead:
proper g model Tag name:str
Namespaced controllers
You can scope a controller under a subfolder with --namespace=admin. The generator creates controllers/admin/post_controller.py, mounts its routes under /admin/..., and gives them prefixed names like Admin:Post.show. The Routing guide covers the details.
Why the generator matters
The generator wires up __init__.py imports, picks the right route names, creates the migration, and lays files out so Proper's conventions hold. Hand-rolling a controller is possible, but you'll spend more time fixing imports than writing code.
3. The Shape of a Generated Controller
Let's look at what proper g resource Card title:str body:text produces, simplified slightly:
# myapp/controllers/card_controller.py
from proper.errors import NotFound
from ..forms.card import CardForm
from ..models import Card
from ..router import router
from .app_controller import AppController
@router.resource("cards")
class CardController(AppController):
before = [
{"do": "set_card", "exclude": ["index", "new", "create"]},
{"do": "set_form", "exclude": ["index", "show", "delete"]},
{"do": "validate_form", "only": ["create", "update"]},
]
def index(self):
self.cards = Card.select()
def show(self):
pass
def new(self):
pass
def edit(self):
pass
def create(self):
card = self.form.save()
self.response.redirect_to("Card.show", card, flash="Card was created")
def update(self):
card = self.form.save()
self.response.redirect_to("Card.show", card, flash="Card was updated")
def delete(self):
if self.card:
self.card.delete_instance()
self.response.redirect_to("Card.index", flash="Card was deleted")
# Private
def set_card(self):
card_id = self.params.get("card_id", "")
self.card = Card.get_or_none(id=int(card_id))
if self.request.matched_action != "delete" and not self.card:
raise NotFound
def set_form(self):
obj = getattr(self, "card", None)
self.form = CardForm(self.params, object=obj)
There's a lot in there, but the pattern repeats across every resource you'll ever generate. Let's walk through it.
Note
You can tell the generators that you only want some of the actions, e.g.:
proper g controller Post --only=index,show
or all of the action except some
proper g controller Post --exclude=new,create
3.1 CRUD
index, new, create, show, edit, update, and delete are the seven CRUD actions Proper recognizes. Together they cover every operation a user can perform on a collection of records:
| Action | Purpose |
|---|---|
index |
List all records. |
new |
Show a blank form for creating a record. |
create |
Receive that form and create the record. |
show |
Display a single record. |
edit |
Show a form pre-filled for editing a record. |
update |
Receive that form and save the changes. |
delete |
Remove a record. |
The split between new / create and edit / update mirrors the two-step nature of HTML forms: one URL renders the form, another receives it. new and edit are GET requests; create, update, and delete change data.
3.2 The Setup Callbacks
Look at the top of the class:
before = [
{"do": "set_card", "exclude": ["index", "new", "create"]},
{"do": "set_form", "exclude": ["index", "show", "delete"]},
{"do": "validate_form", "only": ["create", "update"]},
]
Three before callbacks, listed in the order they run. Each one narrows its scope with only or exclude - declarations Proper reads to decide which actions the callback applies to.
set_card runs for show, edit, update, and delete - every action that operates on an existing record. It loads the card by ID and stashes it on self.card:
def set_card(self):
card_id = self.params.get("card_id", "")
self.card = Card.get_or_none(id=int(card_id))
if self.request.matched_action != "delete" and not self.card:
raise NotFound
The delete exception is deliberate: deleting the same card twice (someone double-clicks the button) shouldn't 404 - we want it to silently succeed.
set_form runs for new, edit, create, and update - every action that renders or processes a form. It builds the form with the request params, pre-filled from the loaded card if there is one:
def set_form(self):
obj = getattr(self, "card", None)
self.form = CardForm(self.params, object=obj)
For new and create, no card has been loaded (set_card was excluded), so obj is None and the form starts empty. For edit and update, the form is pre-filled from self.card.
3.3 Form Validation
The third callback, validate_form, runs only on create and update. It comes from the FormValidation concern your AppController mixes in:
# myapp/controllers/concerns/form_validation.py
def validate_form(self):
form = getattr(self, "form", None)
if form and form.is_invalid:
self.redo()
If the form is invalid, self.redo() re-renders the new or edit template with a 422 Unprocessable Entity status, and the action never runs. If the form is valid, control passes on to the action.
3.4 The Action Bodies
Once the three setup callbacks have done their work, the action methods are tiny:
indexqueries for cards and assigns them toself.cards.show,new, andeditare empty - they just render their templates with whatever the callbacks set up.createcallsself.form.save()to build and INSERT the model, then redirects with a flash message.updateis almost identical -self.form.save()applies the changes to the card thatset_cardloaded and UPDATEs the row.deleteremoves the row and redirects to the index.
This is the shape of nearly every controller you'll write: small actions, with the heavy lifting (loading, form-building, validation) factored into callbacks above them.
Note
Callbacks set things up; actions decide what happens; templates render the result.
4. @router.resource
The @router.resource decorator does most of the work of mounting a controller. It takes one positional argument and a few keyword options that adjust the URLs it generates.
4.1 The Path
The first argument is the URL prefix the resource lives under:
@router.resource("cards")
class CardController(AppController):
...
It's almost always the plural form of the controller's name, lowercased. Multi-word resources are kebab-cased: @router.resource("user-profiles").
4.2 The ID Parameter (pk=)
By default, Proper builds the ID parameter by taking the controller's name (minus the Controller suffix), converting it to snake_case, and appending _id:
CardController->:card_idUserPhotoController->:user_photo_id
Override it with the pk argument when you need a different name. Whatever you pass becomes the parameter name verbatim - Proper won't append _id for you:
@router.resource("cards", pk="object")
Now show's URL is /cards/:object, and inside the controller you read self.params["object"]. If you want an _id suffix, include it yourself: pk="object_id".
4.3 Singular Resources (pk=None)
Some resources don't have an ID at all - the current user's profile, the dashboard, the site settings. There's only ever one. Pass pk=None to drop the ID parameter entirely:
@router.resource("profile", pk=None)
class ProfileController(AppController):
...
The URL table changes:
| HTTP | PATH | ACTION |
|---|---|---|
| GET | /profile/new | new |
| POST | /profile | create |
| GET | /profile | show |
| GET | /profile/edit | edit |
| PATCH | /profile | update |
| PUT | /profile | update |
| DELETE | /profile | delete |
index is gone - without an ID, there's no distinction between "the list" and "the single item," so listing doesn't make sense. The :profile_id segment is removed from every other path as well.
Scoped routers
For namespaced controllers, you'll see @admin_router.resource(...) instead of @router.resource(...). Same arguments, same behavior - the scoped router just prepends the namespace prefix to every URL it generates. See the Routing guide for more.
5. Where the URLs Come From
Once you've mounted a controller with @router.resource("cards"), the seven CRUD actions become seven URLs:
| HTTP | PATH | ACTION | USED FOR |
|---|---|---|---|
| GET | /cards | index | a list of all cards |
| GET | /cards/new | new | form for creating a new card |
| POST | /cards | create | create a new card |
| GET | /cards/:card_id | show | show a specific card |
| GET | /cards/:card_id/edit | edit | form for editing a specific card |
| PATCH | /cards/:card_id | update | update a specific card |
| PUT | /cards/:card_id | update | replace a specific card |
| DELETE | /cards/:card_id | delete | delete a specific card |
You don't write these URLs yourself; the router builds them from the actions you define on the class.
Missing methods, missing routes
If one of the seven actions is not defined on the class, its URL is not created. A controller with only index and show will have routes for GET /cards and GET /cards/:card_id, and that's it. There's no 405 for POST /cards - the route simply doesn't exist.
This is intentional: the routes mirror the controller. To remove an action from the public surface, delete the method.
One special case: if new is defined but index (or show, for pk=None resources) is not, the new action is mounted at /cards instead of /cards/new. The root path is free, so it's used.
The Routing guide goes deeper - manually-defined routes with @router.get, @router.post, etc., named routes, route inspection, and scoped routers.
6. Parameters
Data sent by the incoming request is available in your controller via self.params. It's a MultiDict that merges three sources:
- Query string parameters - from the URL (e.g.,
?status=activated). - Form body parameters - submitted from an HTML form, including file uploads.
- Route parameters - extracted from the URL path (e.g.,
:card_id).
If the same key appears in more than one source, route parameters win, then the form, then the query string. So :card_id from the URL will always override a card_id smuggled in via the query string.
@router.resource("clients")
class ClientController(AppController):
# GET /clients?status=activated
def index(self):
if self.params.get("status") == "activated":
self.clients = Client.get_activated()
else:
self.clients = Client.get_inactivated()
# POST /clients
def create(self):
client = self.form.save()
self.response.redirect_to("Client.show", client, flash="Client was created")
6.1 Reading Parameters
Three methods cover almost every case:
self.params.get("key") # value, or None if missing
self.params["key"] # value, or KeyError if missing
self.params.getall("key") # list of all values for the key
Because self.params is a MultiDict, self.params["key"] returns the last value when the same key was sent multiple times. That's almost always what you want for plain text fields, but it's the wrong choice for a multi-select or a checkbox group:
<input type="checkbox" name="tags" value="python">
<input type="checkbox" name="tags" value="javascript">
<input type="checkbox" name="tags" value="ruby">
If the user ticks all three, self.params["tags"] returns only "ruby". Use self.params.getall("tags") to get the full list.
6.2 Sources Individually
When you need to know where a parameter came from - for example, to reject a value that should only ever arrive via a route segment - the three sources are also available individually:
self.request.query # query string only (MultiDict)
self.request.form # POST body only (MultiDict)
self.request.matched_params # route params only (dict)
Reach for self.params first; the merged view is what you almost always want.
6.3 File Uploads
Uploaded files arrive in self.request.form alongside the regular form fields. Each file is an object with a filename, a content type, and a .read() / .save() API:
def create(self):
upload = self.request.form.get("avatar")
if upload:
upload.save(f"/var/uploads/{upload.filename}")
For attaching files to model records, see the File Storage guide - it gives you S3 support, content-type validation, and image variants without you having to write the plumbing.
6.4 Route Defaults
Routes can attach default values that flow into the controller as self.defaults:
# in the router
@router.get("pages/:slug", defaults={"sidebar": True})
def show(self):
...
# in the controller
self.defaults["sidebar"] # True
self.defaults is separate from self.params on purpose - defaults are set by you when wiring the route, and shouldn't be confused with values the user submitted.
7. Rendering Responses
Once an action has done its work, Proper needs to turn the result into bytes the browser can understand. Most of the time that means rendering a template, but you can also return JSON, plain text, or a string straight from the action.
7.1 Implicit Rendering
If an action returns None and doesn't set the response body, Proper picks the right template for you. The simplest action is one that does nothing:
def show(self):
self.card = Card.get_by_id(self.params["card_id"])
# automatically renders pages/card/show.jx
The lookup walks the controller's class hierarchy and the request's Accept header to find the best match - so an admin controller subclassing a public one inherits its template folder as a fallback, and a request for application/json can be handled by a .json.jx template if one exists. The Jx Components and Layouts guide covers the full algorithm; for most pages, you don't have to think about it - the file at pages/<controller>/<action>.jx is what gets rendered.
7.2 Template Variables
Every instance attribute you set on the controller is passed to the template as a variable:
def show(self):
self.card = Card.get_by_id(self.params["card_id"])
self.related = (
Card.select()
.where(Card.category == self.card.category)
.limit(5)
)
# {{ card }} and {{ related }} are now available in show.jx
There's no manual context dictionary to assemble - if you wrote self.foo = ..., the template can use {{ foo }}.
7.3 Explicit Rendering
When you need a different template, or want to set a custom status, call self.render():
def show(self):
self.card = Card.get_or_none(self.params["card_id"])
if not self.card:
return self.render("pages/card/not_found.jx", status=404)
The path you pass goes straight to the template catalog - the prefix chain and format negotiation that implicit rendering does are skipped.
7.4 JSON
Pass json= to render a value as JSON:
def show(self):
card = Card.get_by_id(self.params["card_id"])
return self.render(json={"id": card.id, "title": card.title})
This sets Content-Type: application/json and serializes the value with a custom encoder that handles datetime objects.
For full API patterns - content negotiation between HTML and JSON, JSON-only controllers, JSON error responses - see the Building API-only Applications guide.
7.5 Plain Text
Pass text= for text/plain responses:
def healthcheck(self):
return self.render(text="ok")
7.6 Returning a String Directly
If an action returns any non-None value, that value becomes the response body verbatim. You can set the content type yourself before returning:
def healthcheck(self):
self.response.content_type = "text/plain"
return "ok"
This works for any content type (text/plain, text/csv, application/xml, ...). For the common cases self.render(text=...) and self.render(json=...) are easier, since they set the content type for you.
7.7 Custom Status Codes
Every render mode accepts a status argument:
return self.render(json={"id": card.id}, status=201)
return self.render(text="created", status=201)
You can also set the status on the response object directly, which is handy when a callback decides what status to use:
from proper import status
self.response.status = status.created
Status codes are available as integers under proper.status (e.g., proper.status.unprocessable is 422).
8. Redirecting
After a successful create, update, or delete, you almost always want to send the user somewhere else - typically the page that shows what they just changed. Use self.response.redirect_to():
# named route + the object (preferred)
self.response.redirect_to("Card.show", card)
# named route with an explicit ID
self.response.redirect_to("Card.show", card_id=card.id)
# absolute URL
self.response.redirect_to("/dashboard")
# external URL
self.response.redirect_to("https://example.com")
Redirects default to 303 See Other, which is the right status after a POST.
Why 303, not 302
Both work in practice - every modern browser treats a 302 after a POST as if it were 303. The reason Proper picks 303 is intent: 303 explicitly says "the response is at another URL, fetch it with GET regardless of the original method," which is exactly the post-redirect-get pattern you want after a successful create or update. 302 was historically ambiguous about whether the method should change between the original request and the redirect.
Override the default for permanent redirects or other cases:
from proper import status
self.response.redirect_to("/new-location", status=status.moved_permanently)
Pass the object, not its ID
When a route takes an ID, prefer redirect_to("Card.show", card) over redirect_to("Card.show", card_id=card.id). Both work, but passing the object reads better and survives renames of the route's ID parameter.
8.1 Generating URLs: url_for
url_for is the same naming system as redirect_to, used wherever you need a URL string instead of an HTTP redirect. In Python:
app.url_for("Card.index") # /cards
app.url_for("Card.show", card) # /cards/42
In a template:
<a href="{{ url_for('Card.show', card) }}">View card</a>
Named routes follow a Controller.action convention. Namespaced controllers prefix the namespace in PascalCase: Admin:Post.show. The Routing guide covers the full naming rules and how to inspect the routes your app has.
8.2 Flash Messages
A flash message is a one-shot bit of text that survives a single redirect - "Card was created", "Try again in a few minutes". Set one as part of a redirect:
self.response.redirect_to(
"Card.index",
flash="Card was deleted",
)
self.response.redirect_to(
"Session.new",
flash="Try again in a few minutes.",
flash_type="error",
)
flash_type defaults to "info". Common types are "info", "success", "warning", and "error" - your layout decides how each one looks.
You can also set a flash message without redirecting, useful when an action renders a page itself but still wants to acknowledge something:
self.response.flash.message("success", "Settings saved")
In your templates, flash messages are available as a list of (type, message) tuples; the layout that ships with the generator already renders them.
9. Sessions
The session is a small bag of data that follows the user across requests. It's stored in a signed cookie, so users can't tamper with it - which is why it's safe to use for things like the current user's ID.
Read from self.request.session, write to self.response.session. The session is a DotDict, so dict-style and attribute-style access both work:
# read
value = self.request.session.get("key")
value = self.request.session.key
# write
self.response.session["key"] = value
self.response.session.key = value
Proper compares the request and response sessions at the end of every request, and only writes a new cookie if anything changed - so reads alone don't cost you a cookie roundtrip.
Why two sessions?
Splitting reads and writes makes it explicit which values were "what we got from the user" and which are "what we're about to send back." Without that split, a callback that reads a value to make a decision would look identical to one that writes a value to change state, and a misplaced assignment could silently overwrite the user's session.
10. Cookies
The session is the most common reason to set a cookie, but you can set arbitrary cookies too - language preferences, theme choices, dismissed banners.
10.1 Plain Cookies
# read
theme = self.request.get_cookie("theme", default="light")
# write
self.response.set_cookie("theme", "dark", max_age=31536000)
# delete
self.response.unset_cookie("theme")
Sensible defaults are baked in: cookies are scoped to path="/", marked samesite="Lax", and last only for the browser session unless you pass max_age. Common options:
| Option | Purpose |
|---|---|
max_age |
Lifetime in seconds. Omit for a session-only cookie. |
path |
URL path the cookie applies to. Defaults to "/". |
domain |
Subdomain restriction. Defaults to the current host. |
secure |
Only sent over HTTPS. Recommended for anything sensitive. |
httponly |
Hidden from JavaScript. Recommended for anything sensitive. |
samesite |
"Lax" (default), "Strict", or "None". |
10.2 Signed Cookies
A plain cookie is data the user can read and edit - they can open DevTools and rewrite the value to anything they want. A signed cookie is the same data plus a cryptographic signature; if the user edits it, the signature stops matching and Proper rejects it.
Reach for signed cookies whenever the value is something the user shouldn't be able to forge - a CSRF token, a "remember me" token, a stored user ID:
# write
self.response.set_signed_cookie("_auth", user.id, max_age=2592000, httponly=True)
# read - returns None if the signature doesn't match
user_id = self.request.get_signed_cookie("_auth", max_age=2592000)
The max_age on the read side isn't just a hint: signed cookies older than max_age are also rejected, even if the signature is still valid. This protects against an attacker reusing a stolen-but-old cookie.
Don't put secrets in cookies, signed or not
Signed cookies prevent tampering, not reading. Anyone with browser access can still see what's in them. Store IDs and references in cookies; keep the actual sensitive data on the server.
11. Request and Response Objects
Most of what you read from self.request and write to self.response has come up in the previous sections - parameters, sessions, cookies, status, content type. The objects have a handful more attributes that come up less often but are worth knowing.
11.1 Useful on self.request
self.request.method # "GET", "POST", "PATCH", ...
self.request.path # "/cards/42"
self.request.url # full URL including scheme and host
self.request.headers # incoming headers, MultiDict with lowercase keys
self.request.format # "html", "json", ... (from the Accept header)
self.request.is_xhr # True if X-Requested-With: XMLHttpRequest
self.request.remote_ip # client IP address
self.request.matched_action # the action name the router matched ("show", "create", ...)
11.2 Useful on self.response
self.response.headers["X-Custom"] = "value"
self.response.set_cache_control("max-age=3600", "public")
self.response.send_file(
"/path/to/report.pdf",
as_attachment=True,
download_name="Q4-Report.pdf",
)
send_file is the one to reach for when an action's job is to return a file from disk - a generated PDF, an export, a rendered chart. It sets the content type, the right headers, and (with as_attachment=True) prompts the browser to download instead of display.
12. Before and After Callbacks
A callback is a method that runs automatically around your action - either before it (before) or after it (after). You've already seen one: the generated controller's set_card is a before callback that loads the card and builds the form on every action that needs them.
The motivation is straightforward: most controllers end up with the same setup at the top of every action. Loading a record, checking permissions, setting response headers. Callbacks let you write that setup once and have it run automatically.
12.1 The before Callback
Declare a before callback by assigning a dict to the class attribute before:
class CardController(AppController):
before = {"do": "set_card", "exclude": ["index", "new", "create"]}
def set_card(self):
card_id = self.params.get("card_id", "")
self.card = Card.get_or_none(id=int(card_id))
if self.request.matched_action != "delete" and not self.card:
raise NotFound
def show(self):
# self.card is already loaded
pass
The dict has three keys:
| Key | Meaning |
|---|---|
do |
The name of the method to call. Required. |
only |
A list of action names to run on. Optional. |
exclude |
A list of action names to skip. Optional. |
If neither only nor exclude is given, the callback runs for every action.
12.2 Multiple Callbacks
Pass a list of dicts to declare more than one:
class CardController(AppController):
before = [
{"do": "set_card", "exclude": ["index"]},
{"do": "ensure_editor", "only": ["edit", "update", "delete"]},
]
def set_card(self):
...
def ensure_editor(self):
if not current.user.can_edit(self.card):
raise Forbidden
Callbacks run in the order you list them. set_card runs first, then ensure_editor - which depends on self.card being set.
12.3 Halting
If a before callback sets the response body - by calling self.render(...), self.response.redirect_to(...), or assigning self.response.body directly - the action never runs. Subsequent before callbacks don't run either. The framework treats it as "this callback already produced a response; we're done here."
class AdminController(AppController):
before = {"do": "require_admin"}
def require_admin(self):
if not current.user.is_admin:
self.response.redirect_to("Home.show", flash="Admin only.")
# action never runs
Raising an HTTP error class (NotFound, Forbidden, ...) also halts the dispatch, but via the error-handler path rather than the response body. Both stop the action from running.
A halted before is logged
If a before callback halts the action, the framework logs it at DEBUG level. If something is mysteriously not running, check the logs for "halted by before callback".
12.4 The after Callback
after callbacks run after the action has produced a response. They have access to the response that's about to be sent, which makes them useful for setting headers or post-processing the body:
class CardController(AppController):
after = {"do": "set_no_cache", "only": ["edit"]}
def set_no_cache(self):
self.response.set_cache_control("no-store")
Same dict shape as before: do, only, exclude. Same support for a list of dicts when you have several.
after doesn't run after errors
after callbacks only run when the action completed successfully. If the action (or a before callback) raised an exception, the after callbacks are skipped and the framework's error handler takes over. So don't use after for cleanup that has to happen unconditionally - use a try/finally inside the action, or a concern method called explicitly.
12.5 Inheritance
Callbacks declared on AppController (or any parent class) run for every controller that inherits from it. Order matters and is one of the most-asked questions about callbacks in any framework, so:
beforecallbacks run outer-first: parent class → child class.aftercallbacks run inner-first: child class → parent class.
The pattern is "child wraps parent." For a single request:
parent.before → child.before → action → child.after → parent.after
This means a before callback on AppController always runs before any before callback on a child controller. The classic example is requiring login:
class AppController(Controller):
before = {"do": "require_login"}
def require_login(self):
if not current.user:
self.response.redirect_to("Session.new")
class CardController(AppController):
before = {"do": "set_card", "exclude": ["index"]}
def set_card(self):
# safe to query the DB here - require_login already ran
self.card = Card.get_or_none(...)
A logged-out request to GET /cards/42 runs require_login first. It redirects to the login page and sets the response body, halting the dispatch - so set_card never runs and the database is never queried.
This is the right order: cheap, broad checks first; expensive, specific work later. Concerns mixed into AppController (origin protection, rate limiting, security headers) are also "outer" relative to your child controllers, and run in that same order.
13. Concerns
A concern is a class that bundles callbacks and helper methods so you can share them across controllers. They're plain Python mixins, but they inherit from proper.Concern rather than Controller, which lets the framework distinguish "behavior to mix in" from "controllers to mount."
You've already met four of them. Open myapp/controllers/app_controller.py and you'll see:
class AppController(
Controller,
OriginProtection,
RateLimiting,
FormValidation,
SecurityHeaders,
):
pass
Those four ship with every Proper application. Two come from the framework (OriginProtection, RateLimiting); two live in your app under myapp/controllers/concerns/ so you can edit them (FormValidation, SecurityHeaders).
13.1 The Built-in Concerns
13.1.1 OriginProtection
Modern CSRF protection. It verifies that state-changing requests (POST, PATCH, PUT, DELETE) come from a trusted origin, using the browser's Sec-Fetch-Site and Origin headers - no tokens needed. Safe methods (GET, HEAD, OPTIONS, QUERY) are skipped; cross-origin state changes raise 403 Forbidden.
To allow requests from other domains (an admin subdomain, a CDN), list them in your config:
# config/main.py
TRUSTED_ORIGINS = [
"https://cdn.example.com",
"https://admin.example.com",
]
The full algorithm is covered in the Security guide.
13.1.2 RateLimiting
Lets you cap how many requests a controller will accept in a time window. The concern is wired up but does nothing until you set rate_limit on a controller:
from proper.units import MINUTES
class SessionController(AppController):
rate_limit = {"to": 10, "within": 3 * MINUTES, "only": "create"}
This allows ten POSTs to Session.create per IP per three minutes. Anything beyond raises 429 Too Many Requests. The concern can identify clients by something other than IP, run different limits on different actions, and hand off to a custom reaction method instead of raising - the Security guide covers the full surface.
Rate limiting needs a configured cache store. If no cache is configured, the limits are silently ignored.
13.1.3 FormValidation
Provides the validate_form method that the generator wires into every resource controller's before list. It checks self.form and, if invalid, calls self.redo() - which re-renders the form template with a 422 Unprocessable Entity status, halting the dispatch.
The concern lives in your app at myapp/controllers/concerns/form_validation.py, so you can change it. The default does the right thing for HTML forms; for JSON APIs you'll typically skip it and validate inline (see the API-only Applications guide).
13.1.4 SecurityHeaders
Sets a small set of safe-by-default response headers via an after callback. The default lives at myapp/controllers/concerns/security_headers.py:
class SecurityHeaders(Concern):
after = {"do": "_set_security_headers"}
def _set_security_headers(self):
self.response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
self.response.headers.setdefault("X-XSS-Protection", "1", mode="block")
self.response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
...
Edit it to fit your application's threat model - adding a Content Security Policy, tightening the referrer policy, or whatever else your security review asks for.
13.2 Writing Your Own
A concern is the right place to put behavior that more than one controller needs. The convention is one concern per file under myapp/controllers/concerns/:
# myapp/controllers/concerns/team_scoped.py
from proper import Concern
from proper.errors import NotFound
from ...models import Team
class TeamScoped(Concern):
before = {"do": "set_team"}
def set_team(self):
team_id = self.params.get("team_id")
self.team = Team.get_or_none(id=team_id)
if not self.team:
raise NotFound
Mix it into any controller that operates inside a team:
class ProjectController(TeamScoped, AppController):
def index(self):
# self.team was loaded by the concern
self.projects = self.team.projects
A few rules of thumb:
- List concerns before
AppControllerwhen mixing them into individual controllers (TeamScoped, AppController). Python's MRO uses left-to-right order, and listing the concern first makes its callbacks run closer to the child thanAppController's do. - Keep concerns focused. "Loads a team," "checks editor access," "tracks page views" - one job each. If a concern grows past 30 or 40 lines, it's probably doing too much.
- Concerns aren't a way to share state. They're a way to share behavior. If two controllers need the same data, that's usually a model concern (covered in the Models guide) or a helper, not a controller concern.