In Part 2, you learned how F3 routes URLs to your code. Your routes probably used echo to output HTML directly — fine for demos, but real apps need proper templates.
This part teaches you how to separate your presentation (HTML) from your logic (PHP). You'll learn F3's built-in template engine, how to pass data from controllers to views, and when to use external engines like Twig.
1. The Problem: Spaghetti HTML
Without templates, your route handlers might look like this:
// ❌ Bad: HTML mixed with PHP logic
$f3->route('GET /profile', function($f3) {
$user = get_user_from_db($f3->get('SESSION.user_id'));
echo '<html>';
echo '<head><title>' . $user['name'] . '</title></head>';
echo '<body>';
echo '<h1>Welcome, ' . htmlspecialchars($user['name']) . '</h1>';
echo '<p>Email: ' . htmlspecialchars($user['email']) . '</p>';
if ($user['is_admin']) {
echo '<a href="/admin">Admin Panel</a>';
}
echo '</body></html>';
}); This works, but it's a nightmare to maintain. Want to change the layout? Edit PHP. Want to adjust the HTML? Edit PHP. Designer on your team? They can't touch this.
The solution: separate your logic (PHP) from your presentation (HTML).
2. PHP as a Template Engine
F3 supports plain PHP templates. First, create a views/ folder in your project root, then create views/profile.htm:
<!DOCTYPE html>
<html>
<head>
<title><?= $title ?></title>
</head>
<body>
<h1>Welcome, <?= htmlspecialchars($name) ?></h1>
<p>Email: <?= htmlspecialchars($email) ?></p>
</body>
</html> Then render it from your route:
$f3->route('GET /profile', function($f3) {
// In a real app, fetch from database
$user = ['name' => 'Dimas', 'email' => '[email protected]'];
$f3->set('title', 'My Profile');
$f3->set('name', $user['name']);
$f3->set('email', $user['email']);
echo \View::instance()->render('views/profile.htm');
}); <?= ?> tags. You have full PHP power but risk mixing logic into your template.Template = F3 template engine. Uses
{{ @var }} syntax. Escapes by default, no PHP allowed. Forces separation.Both render the same result. Choose based on your team's discipline.
3. F3 Template Syntax
F3 has its own template engine that uses {{ @variable }} syntax instead of PHP. Create the same profile as an F3 template:
<!DOCTYPE html>
<html>
<head>
<title>{{ @title }}</title>
</head>
<body>
<h1>Welcome, {{ @name }}</h1>
<p>Email: {{ @email }}</p>
</body>
</html> The @ prefix tells F3 to look up the variable in its internal storage. Render it with the Template class:
$f3->route('GET /profile', function($f3) {
// In a real app, fetch from database
$user = ['name' => 'Dimas', 'email' => '[email protected]'];
$f3->set('title', 'My Profile');
$f3->set('name', $user['name']);
$f3->set('email', $user['email']);
echo \Template::instance()->render('views/profile.htm');
}); 4. Variables & Expressions
Basic Variables
// Set variables
$f3->set('name', 'Dimas');
$f3->set('title', 'My Blog'); <h1>{{ @title }}</h1>
<p>Hello, {{ @name }}!</p> Arrays and Dot Notation
Dot notation accesses array keys:
$f3->set('user.name', 'Dimas'); // $user['name']
$f3->set('user.email', '[email protected]'); // $user['email'] <p>Name: {{ @user.name }}</p>
<p>Email: {{ @user.email }}</p> @foo.bar = $foo['bar'] (array access)@foo.@bar = $foo . $bar (string concatenation)Easy to mix up. Be careful.
Expressions
You can embed expressions inside templates:
<!-- Arithmetic -->
<p>Page {{ @page }} of {{ @total_pages }}</p>
<p>Item {{ (@page - 1) * @per_page + 1 }} - {{ @page * @per_page }}</p>
<!-- Ternary -->
<option {{ @active ? 'selected' : '' }}>Active</option>
<!-- Function calls -->
<p>Uppercase: {{ strtoupper(@name) }}</p>
<!-- Array items -->
<p>First item: {{ @items[0] }}</p> 5. Conditionals
Use the <check> directive for if/else logic in templates:
Basic If/Else
<check if="{{ @loggedin }}">
<true>
<p>Welcome back, {{ @user.name }}!</p>
<a href="/logout">Logout</a>
</true>
<false>
<p>Please <a href="/login">log in</a>.</p>
</false>
</check> Simple If (No False Block)
<!-- If loggedin is truthy, show this -->
<check if="{{ @loggedin }}">
<p>You are logged in.</p>
</check> Complex Conditions
<check if="{{ @user.role == 'admin' && @user.active }}">
<true><a href="/admin">Admin Panel</a></true>
<false><!-- no admin link --></false>
</check> <check> evaluates to <false> if the value is: NULL, empty string, FALSE, empty array, or 0. This is standard PHP truthiness.6. Loops
Use the <repeat> directive to iterate over arrays:
Basic Loop
$f3->set('fruits', ['Apple', 'Banana', 'Orange']); <ul>
<repeat group="{{ @fruits }}" value="{{ @fruit }}">
<li>{{ @fruit }}</li>
</repeat>
</ul> Output:
<ul>
<li>Apple</li>
<li>Banana</li>
<li>Orange</li>
</ul> Loop with Key
$f3->set('user', ['name' => 'Dimas', 'role' => 'admin']); <dl>
<repeat group="{{ @user }}" key="{{ @key }}" value="{{ @val }}">
<dt>{{ @key }}</dt>
<dd>{{ @val }}</dd>
</repeat>
</dl> Loop with Counter
<repeat group="{{ @items }}" value="{{ @item }}" counter="{{ @ctr }}">
<p class="{{ @ctr % 2 ? 'odd' : 'even' }}">{{ @item }}</p>
</repeat> Nested Loops
$f3->set('menu', [
'Fruits' => ['Apple', 'Banana'],
'Vegetables' => ['Carrot', 'Broccoli']
]); <repeat group="{{ @menu }}" key="{{ @category }}" value="{{ @items }}">
<h3>{{ @category }}</h3>
<ul>
<repeat group="{{ @items }}" value="{{ @item }}">
<li>{{ @item }}</li>
</repeat>
</ul>
</repeat> <repeat> compiles to PHP foreach — it's fast, but rendering 10,000 DOM elements is slow regardless of template engine.7. Template Includes
Real apps have shared headers, footers, and sidebars. F3 uses <include> to embed other templates.
Basic Include
<!-- layout.htm -->
<include href="views/header.htm" />
<main>
{{ @content | raw }}
</main>
<include href="views/footer.htm" /> Dynamic Includes
You can include templates based on variables:
// Switch content based on route
$f3->set('content', 'views/blog.htm');
// In another route:
$f3->set('content', 'views/wiki.htm'); <include href="{{ @content }}" /> Include with Parameters
<!-- Pass variables to sub-template -->
<include href="views/sidebar.htm" with="title='Menu',show_icons=true" /> Conditional Include
<!-- Only include if user is logged in -->
<include if="{{ @loggedin }}" href="views/user-menu.htm" /> layout.htm that includes shared elements and a content variable:<include href="header.htm" />{{ @content | raw }}<include href="footer.htm" />Each route sets
$f3->set('content', '...') to control what appears in the middle.8. Passing Data to Views
The pattern is always the same: set variables in your controller, consume them in your template.
$f3->route('GET /dashboard', function($f3) {
// Fetch data (replace with real DB queries)
$user = ['name' => 'Dimas', 'role' => 'admin'];
$stats = ['post_count' => 42, 'views_today' => 156];
$notifications = [
['message' => 'New comment on your post'],
['message' => 'Welcome back!']
];
// Set template variables
$f3->set('page_title', 'Dashboard');
$f3->set('user', $user);
$f3->set('stats', $stats);
$f3->set('notifications', $notifications);
$f3->set('content', 'views/dashboard.htm');
// Render layout with content
echo \Template::instance()->render('views/layout.htm');
}); <!-- views/dashboard.htm -->
<h1>Welcome, {{ @user.name }}</h1>
<div class="stats">
<p>Total posts: {{ @stats.post_count }}</p>
<p>Views today: {{ @stats.views_today }}</p>
</div>
<check if="{{ count(@notifications) > 0 }}">
<true>
<h2>Notifications</h2>
<ul>
<repeat group="{{ @notifications }}" value="{{ @note }}">
<li>{{ @note.message }}</li>
</repeat>
</ul>
</true>
<false>
<p>No new notifications.</p>
</false>
</check> 9. Escaping & Sanitization
F3's template engine (using Template class) automatically escapes all variables to prevent XSS attacks. This means < becomes < and is displayed as literal text, not HTML.
Note: PHP templates (using View class) do NOT auto-escape. You must use htmlspecialchars() manually on every output.
Auto-Escaping (Default)
$f3->set('user_input', '<script>alert("XSS")</script>'); <!-- Safe: rendered as text, script won't execute -->
<p>{{ @user_input }}</p>
<!-- Output: <script>alert("XSS")</script> --> Raw Output (Use Carefully)
When you need to render trusted HTML (like content from a WYSIWYG editor), use the raw filter:
<!-- Render HTML as-is (no escaping) -->
<div>{{ @article.html_content | raw }}</div> | raw for content you trust. Never use it on user input unless you've sanitized it first. A malicious script in | raw content can steal cookies, redirect users, or worse.Sanitizing User Input
Use $f3->scrub() to clean input before storing or displaying:
// Remove all HTML tags
$clean = $f3->scrub($user_input);
// Allow specific tags
$clean = $f3->scrub($user_input, 'p; br; strong; em');
// Allow all tags but remove unsafe characters
$clean = $f3->scrub($user_input, '*'); 10. External Template Engines
F3 works with any template engine. If you prefer Twig or Smarty, you can use them instead.
Comparison
| Feature | F3 Templates | Twig | Smarty |
|---|---|---|---|
| Variable syntax | {{ @var }} | {{ var }} | {$var} |
| Template inheritance | Include only | Full block inheritance | Full block inheritance |
| Dependencies | None | twig/twig package | smarty/smarty package |
| Learning curve | Easy | Moderate | Steep |
| Auto-escaping | Yes | Yes | Yes |
| Best for | F3-only projects | Complex layouts | Legacy projects |
When to Use Each
- F3 Templates — Simple projects, minimal dependencies, when you want to stay within F3's ecosystem.
- Twig — Complex layouts with deep inheritance, teams familiar with Symfony (which uses Twig), when you need features like macros and custom tags.
- Smarty — Legacy projects already using Smarty, teams with designers who prefer Smarty syntax.
Consider Twig if: (1) your team already knows it from Symfony, (2) you need complex template inheritance, or (3) you want custom tags and macros.
Don't add Twig just because it's "more popular" — F3 templates are faster to set up and have zero dependencies.
Putting It Together
Here's a complete example that ties together routing (Part 2) with templates:
my-app/
├── index.php
├── views/
│ ├── layout.htm # Main layout with header/footer
│ ├── home.htm # Home page content
│ ├── blog.htm # Blog post list
│ └── post.htm # Single post view
└── assets/
└── style.css <?php
require 'vendor/autoload.php';
$f3 = \Base::instance();
// Sample data (replace with database in real app)
$posts = [
['slug' => 'hello-world', 'title' => 'Hello World', 'body' => 'My first post.'],
['slug' => 'second-post', 'title' => 'Second Post', 'body' => 'Another post.'],
];
// Home page
$f3->route('GET /', function($f3) {
$f3->set('page_title', 'Home');
$f3->set('content', 'views/home.htm');
echo \Template::instance()->render('views/layout.htm');
});
// Blog listing
$f3->route('GET /blog', function($f3) use ($posts) {
$f3->set('page_title', 'Blog');
$f3->set('posts', $posts);
$f3->set('content', 'views/blog.htm');
echo \Template::instance()->render('views/layout.htm');
});
// Single post
$f3->route('GET /blog/@slug', function($f3) use ($posts) {
$slug = $f3->get('PARAMS.slug');
$post = null;
foreach ($posts as $p) {
if ($p['slug'] === $slug) {
$post = $p;
break;
}
}
if (!$post) {
$f3->error(404);
return;
}
$f3->set('page_title', $post['title']);
$f3->set('post', $post);
$f3->set('content', 'views/post.htm');
echo \Template::instance()->render('views/layout.htm');
});
$f3->run(); <!-- views/layout.htm -->
<!DOCTYPE html>
<html>
<head>
<title>{{ @page_title }} - My Blog</title>
<link href="{{ @BASE }}/assets/style.css" rel="stylesheet">
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
</nav>
</header>
<main>
<include href="{{ @content }}" />
</main>
<footer>
<p>© 2026 My Blog</p>
</footer>
</body>
</html> Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Undefined variable error | Variable not set before render | Set all variables with $f3->set() before calling render() |
| Raw output when escaped | Using | raw filter | Remove | raw — auto-escaping is working correctly |
| Template not found | Wrong path or filename | Paths are relative to index.php; check file extension (.htm) |
| Array to string error | Printing array directly | Access array items: @items[0] or loop with <repeat> |
| Include not working | Wrong href path | Use absolute path from root: /views/header.htm |
What's Next?
You now know how to build pages with F3's template engine. In Part 4: Database & ORM, you'll learn:
- Connecting to MySQL and SQLite databases
- The SQL Mapper (Active Record pattern)
- CRUD operations without writing SQL
- Relationships: has-one and has-many
- When to drop down to raw SQL
A blog without a database is just static files. Let's add persistence in Part 4.
Quick reference: fatfreeframework.com/quick-reference