Views
Forge renders templates from resources/views using Jinja2 with Forge
directives. Templates live in .forge.py files.
Rendering
from craft.http.controller import Controller
class PostController(Controller):
def index(self, request):
return self.view("posts.index", {"posts": Post.all()})
View names use dot notation: posts.index resolves to
resources/views/posts/index.forge.py.
Errors propagate. A missing template raises
TemplateNotFound, an undefined variable raisesUndefinedError, and the exception handler turns either into a 500. Forge used to swallow every error and return a placeholder — a broken view looked like a working page.That legacy behaviour survives in one place: the standalone
view()helper incraft.supportstill swallows rendering errors and returns a stub response. Useself.view()in controllers instead.
Layouts
<!-- resources/views/layouts/app.forge.py -->
<!doctype html>
<html>
<head><title>@yield("title")</title></head>
<body>
@yield("content")
</body>
</html>
<!-- resources/views/posts/index.forge.py -->
@extends("layouts.app")
@section("title", "Posts")
@section("content")
<h1>{{ __('recent_posts') }}</h1>
@endsection
@extends and @include accept dot notation and resolve it to a path.
Directives
| Directive | Compiles to |
|---|---|
| @csrf | Hidden _token input |
| @method("PUT") | Hidden _method input |
| @auth … @endauth | Renders only when signed in |
| @guest … @endguest | Renders only when signed out |
| @can('ability', model) … @endcan | Gate check |
| @if(cond) @elseif(cond) @else @endif | Conditional |
| @foreach(items as item) … @endforeach | Loop |
| @extends("layouts.app") | Template inheritance |
| @section("name") … @endsection | Named block |
| @section("name", "value") | Inline block |
| @yield("name") | Block placeholder |
| @include("partials.nav") | Include a template |
Directives are rewritten before Jinja compiles the template, so Jinja syntax works alongside them.
Forms and CSRF
Every state-changing form needs a token. @csrf emits it:
<form method="POST" action="/posts">
@csrf
<input name="title" value="{{ old('title') }}">
<button type="submit">Save</button>
</form>
Without it the request is rejected with 419. See Security.
HTML forms only support GET and POST. For other verbs:
<form method="POST" action="/posts/1">
@csrf
@method("PUT")
</form>
Helpers available in every template
| Helper | Returns |
|---|---|
| csrf_token() | The raw token |
| csrf_field() | The hidden input |
| auth() | The signed-in user, or None |
| can(ability, *args) | Gate check |
| route(name, **params) | URL for a named route |
| config(key, default) | Configuration value |
| session(key, default) | Session value |
| old(key, default) | Previously submitted input |
| __(key, locale) | Translation |
@auth
<p>{{ __('dashboard') }}, {{ auth().get_attribute('name') }}</p>
<a href="{{ route('posts.index') }}">{{ __('recent_posts') }}</a>
@endauth
Sharing data
Make a value available to every template:
class AppServiceProvider(ServiceProvider):
def boot(self):
self.app.make("view").share("app_version", "1.0")
Escaping
Autoescaping is on. {{ value }} escapes HTML:
{{ "<script>alert(1)</script>" }} → <script>alert(1)</script>
Render trusted HTML with | safe, and only when you control the source:
{{ captcha_html | safe }}
Checking a template exists
forge = app.make("view")
if forge.exists("posts.index"):
...